Ticket #2288: 2288-time-queries-r7557.patch
| File 2288-time-queries-r7557.patch, 34.5 KB (added by rblank, 8 weeks ago) |
|---|
-
trac/htdocs/css/report.css
diff --git a/trac/htdocs/css/report.css b/trac/htdocs/css/report.css
a b 38 38 #filters td label { font-size: 11px } 39 39 #filters td.mode { text-align: right } 40 40 #filters td.filter { width: 100% } 41 #filters td.filter label { padding-right: 1em }42 41 #filters td.actions { text-align: right; white-space: nowrap } 43 42 44 43 #columns div label { -
trac/htdocs/js/query.js
diff --git a/trac/htdocs/js/query.js b/trac/htdocs/js/query.js
a b 225 225 td.className = "filter"; 226 226 if (property.type == "select") { 227 227 var element = createSelect(propertyName, property.options, true); 228 td.appendChild(element); 228 229 } else if ((property.type == "text") || (property.type == "textarea")) { 229 230 var element = document.createElement("input"); 230 231 element.type = "text"; 231 232 element.name = propertyName; 232 233 element.size = 42; 234 td.appendChild(element); 235 } else if (property.type == "time") { 236 var element = document.createElement("input"); 237 element.type = "text"; 238 element.name = propertyName; 239 element.size = 14; 240 td.appendChild(element); 241 td.appendChild(document.createTextNode(" ")); 242 td.appendChild(createLabel("and")); 243 td.appendChild(document.createTextNode(" ")); 244 var element2 = document.createElement("input"); 245 element2.type = "text"; 246 element2.name = propertyName + "_end"; 247 element2.size = 14; 248 td.appendChild(element2); 233 249 } 234 td.appendChild(element);235 250 element.focus(); 236 251 tr.appendChild(td); 237 252 } -
trac/ticket/api.py
diff --git a/trac/ticket/api.py b/trac/ticket/api.py
a b 240 240 field = {'name': name, 'type': 'text', 'label': name.title()} 241 241 fields.append(field) 242 242 243 # Date/time fields 244 fields.append({'name': 'time', 'type': 'time', 245 'label': _('Created')}) 246 fields.append({'name': 'changetime', 'type': 'time', 247 'label': _('Modified')}) 248 243 249 for field in self.get_custom_fields(): 244 250 if field['name'] in [f['name'] for f in fields]: 245 251 self.log.warning('Duplicate field name "%s" (ignoring)', … … 278 284 279 285 fields.sort(lambda x, y: cmp(x['order'], y['order'])) 280 286 return fields 287 288 def get_field_synonyms(self): 289 """Return a mapping from field name synonyms to field names. 290 The synonyms are supposed to be more intuitive for custom queries.""" 291 return {'created': 'time', 'modified': 'changetime'} 281 292 282 293 # IPermissionRequestor methods 283 294 -
trac/ticket/model.py
diff --git a/trac/ticket/model.py b/trac/ticket/model.py
a b 40 40 def id_is_valid(num): 41 41 return 0 < int(num) <= 1L << 31 42 42 43 # 0.11 compatibility 44 time_created = property(lambda self: self.values.get('time')) 45 time_changed = property(lambda self: self.values.get('changetime')) 46 43 47 def __init__(self, env, tkt_id=None, db=None, version=None): 44 48 self.env = env 45 49 self.resource = Resource('ticket', tkt_id, version) 46 50 self.fields = TicketSystem(self.env).get_ticket_fields() 51 self.time_fields = [f['name'] for f in self.fields 52 if f['type'] == 'time'] 47 53 self.values = {} 48 54 if tkt_id is not None: 49 55 self._fetch_ticket(tkt_id, db) 50 56 else: 51 57 self._init_defaults(db) 52 self.id = self.time_created = self.time_changed =None58 self.id = None 53 59 self._old = {} 54 60 55 61 def _get_db(self, db): … … 66 72 def _init_defaults(self, db=None): 67 73 for field in self.fields: 68 74 default = None 69 if field['name'] in ['resolution', 'status' ]:75 if field['name'] in ['resolution', 'status', 'time', 'changetime']: 70 76 # Ignore for new - only change through workflow 71 77 pass 72 78 elif not field.get('custom'): … … 93 99 # Fetch the standard ticket fields 94 100 std_fields = [f['name'] for f in self.fields if not f.get('custom')] 95 101 cursor = db.cursor() 96 cursor.execute("SELECT %s ,time,changetimeFROM ticket WHERE id=%%s"102 cursor.execute("SELECT %s FROM ticket WHERE id=%%s" 97 103 % ','.join(std_fields), (tkt_id,)) 98 104 row = cursor.fetchone() 99 105 if not row: … … 102 108 103 109 self.id = tkt_id 104 110 for i in range(len(std_fields)): 105 self.values[std_fields[i]] = row[i] or '' 106 self.time_created = datetime.fromtimestamp(row[len(std_fields)], utc) 107 self.time_changed = datetime.fromtimestamp(row[len(std_fields) + 1], utc) 111 field = std_fields[i] 112 if field in self.time_fields: 113 self.values[field] = datetime.fromtimestamp(row[i], utc) 114 else: 115 self.values[field] = row[i] or '' 108 116 109 117 # Fetch custom fields if available 110 118 custom_fields = [f['name'] for f in self.fields if f.get('custom')] … … 164 172 # Add a timestamp 165 173 if when is None: 166 174 when = datetime.now(utc) 167 self. time_created = self.time_changed= when175 self.values['time'] = self.values['changetime'] = when 168 176 169 177 cursor = db.cursor() 170 178 … … 178 186 # No such component exists 179 187 pass 180 188 189 # Perform type conversions 190 values = dict(self.values) 191 for field in self.time_fields: 192 if field in values: 193 values[field] = to_timestamp(values[field]) 194 181 195 # Insert ticket record 182 created = to_timestamp(self.time_created)183 changed = to_timestamp(self.time_changed)184 196 std_fields = [] 185 197 custom_fields = [] 186 198 for f in self.fields: … … 190 202 custom_fields.append(fname) 191 203 else: 192 204 std_fields.append(fname) 193 cursor.execute("INSERT INTO ticket (%s ,time,changetime) VALUES (%s)"205 cursor.execute("INSERT INTO ticket (%s) VALUES (%s)" 194 206 % (','.join(std_fields), 195 ','.join(['%s'] * (len(std_fields) + 2))),196 [ self[name] for name in std_fields] + [created, changed])207 ','.join(['%s'] * len(std_fields))), 208 [values[name] for name in std_fields]) 197 209 tkt_id = db.get_last_id(cursor, 'ticket') 198 210 199 211 # Insert custom fields … … 289 301 db.commit() 290 302 old_values = self._old 291 303 self._old = {} 292 self. time_changed= when304 self.values['changetime'] = when 293 305 294 306 for listener in TicketSystem(self.env).change_listeners: 295 307 listener.ticket_changed(self, comment, author, old_values) -
trac/ticket/notification.py
diff --git a/trac/ticket/notification.py b/trac/ticket/notification.py
a b 149 149 150 150 def format_props(self): 151 151 tkt = self.ticket 152 fields = [f for f in tkt.fields if f['name'] not in ('summary', 'cc')] 152 fields = [f for f in tkt.fields 153 if f['name'] not in ('summary', 'cc', 'time', 'changetime')] 153 154 width = [0, 0, 0, 0] 154 155 i = 0 155 156 for f in [f['name'] for f in fields if f['type'] != 'textarea']: -
trac/ticket/query.py
diff --git a/trac/ticket/query.py b/trac/ticket/query.py
a b 31 31 from trac.resource import Resource 32 32 from trac.ticket.api import TicketSystem 33 33 from trac.util import Ranges 34 from trac.util.datefmt import to_timestamp, utc34 from trac.util.datefmt import format_datetime, parse_date, 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 _, tag_ … … 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 {} 59 self.order = order 59 synonyms = TicketSystem(self.env).get_field_synonyms() 60 self.order = synonyms.get(order, order) # 0.11 compatibility 60 61 self.desc = desc 61 62 self.group = group 62 63 self.groupdesc = groupdesc … … 100 101 if verbose and 'description' not in rows: # 0.10 compatibility 101 102 rows.append('description') 102 103 self.fields = TicketSystem(self.env).get_ticket_fields() 104 self.time_fields = [f['name'] for f in self.fields 105 if f['type'] == 'time'] 103 106 field_names = [f['name'] for f in self.fields] 104 107 self.cols = [c for c in cols or [] if c in field_names or 105 c in ('id', 'time', 'changetime')]108 c == 'id'] 106 109 self.rows = [c for c in rows if c in field_names] 107 110 if self.order != 'id' and self.order not in field_names: 108 # TODO: fix after adding time/changetime to the api.py 109 if order == 'created': 110 order = 'time' 111 elif order == 'modified': 112 order = 'changetime' 113 if order in ('time', 'changetime'): 114 self.order = order 115 else: 116 self.order = 'priority' 111 self.order = 'priority' 117 112 118 113 if self.group not in field_names: 119 114 self.group = None … … 124 119 kw_strs = ['order', 'group', 'page', 'max'] 125 120 kw_arys = ['rows'] 126 121 kw_bools = ['desc', 'groupdesc', 'verbose'] 122 synonyms = TicketSystem(env).get_field_synonyms() 127 123 constraints = {} 128 124 cols = [] 129 125 for filter_ in filters: … … 131 127 if len(filter_) != 2: 132 128 raise QuerySyntaxError(_('Query filter requires field and ' 133 129 'constraints separated by a "="')) 134 field, values = filter_130 field, values = filter_ 135 131 if not field: 136 132 raise QuerySyntaxError(_('Query filter requires field name')) 137 133 # from last char of `field`, get the mode of comparison … … 157 153 elif field in kw_bools: 158 154 kw[field] = True 159 155 elif field == 'col': 160 cols.extend(processed_values) 156 cols.extend(synonyms.get(value, value) 157 for value in processed_values) 161 158 else: 162 constraints[field] = processed_values 159 constraints.setdefault(synonyms.get(field, field), 160 []).extend(processed_values) 163 161 except UnicodeError: 164 162 pass # field must be a str, see `get_href()` 165 163 report = constraints.pop('report', None) … … 185 183 if col in cols: 186 184 cols.remove(col) 187 185 cols.append(col) 188 # TODO: fix after adding time/changetime to the api.py189 cols += ['time', 'changetime']190 186 191 187 # Semi-intelligently remove columns that are restricted to a single 192 188 # value by a query constraint. … … 195 191 constraint = self.constraints[col] 196 192 if len(constraint) == 1 and constraint[0] \ 197 193 and not constraint[0][0] in ('!', '~', '^', '$'): 198 if col in cols :194 if col in cols and col not in self.time_fields: 199 195 cols.remove(col) 200 196 if col == 'status' and not 'closed' in constraint \ 201 197 and 'resolution' in cols: … … 299 295 result['href'] = req.href.ticket(val) 300 296 elif val is None: 301 297 val = '--' 302 elif name in ('changetime', 'time'):298 elif name in self.time_fields: 303 299 val = datetime.fromtimestamp(int(val or 0), utc) 304 300 elif field and field['type'] == 'checkbox': 305 301 try: … … 425 421 426 422 def get_constraint_sql(name, value, mode, neg): 427 423 if name not in custom_fields: 428 name= 't.' + name424 col = 't.' + name 429 425 else: 430 name= name + '.value'426 col = name + '.value' 431 427 value = value[len(mode) + neg:] 432 428 429 if name in self.time_fields: 430 if ';' in value: 431 (start, end) = [each.strip() for each in 432 value.split(';', 1)] 433 else: 434 (start, end) = (value.strip(), '') 435 col_cast = db.cast(col, 'int') 436 if start and end: 437 start = to_timestamp(parse_date(start, req.tz)) 438 end = to_timestamp(parse_date(end, req.tz)) 439 return ("%s(%s>=%%s AND %s<%%s)" % (neg and 'NOT ' or '', 440 col_cast, col_cast), 441 (start, end)) 442 elif start: 443 start = to_timestamp(parse_date(start, req.tz)) 444 return ("%s%s>=%%s" % (neg and 'NOT ' or '', col_cast), 445 (start, )) 446 elif end: 447 end = to_timestamp(parse_date(end, req.tz)) 448 return ("%s%s<%%s" % (neg and 'NOT ' or '', col_cast), 449 (end, )) 450 else: 451 return None 452 433 453 if mode == '': 434 return ("COALESCE(%s,'')%s=%%s" % (name, neg and '!' or ''), 435 value) 454 return ("COALESCE(%s,'')%s=%%s" % (col, neg and '!' or ''), 455 (value, )) 456 436 457 if not value: 437 458 return None 438 db = self.env.get_db_cnx()439 459 value = db.like_escape(value) 440 460 if mode == '~': 441 461 value = '%' + value + '%' … … 443 463 value = value + '%' 444 464 elif mode == '$': 445 465 value = '%' + value 446 return ("COALESCE(%s,'') %s%s" % ( name, neg and 'NOT ' or '',466 return ("COALESCE(%s,'') %s%s" % (col, neg and 'NOT ' or '', 447 467 db.like()), 448 value)468 (value, )) 449 469 470 db = self.env.get_db_cnx() 450 471 clauses = [] 451 472 args = [] 452 473 for k, v in self.constraints.items(): … … 480 501 clauses.append('%s(%s)' % (neg and 'NOT ' or '', 481 502 ' OR '.join(id_clauses))) 482 503 # Special case for exact matches on multiple values 483 elif not mode and len(v) > 1 :504 elif not mode and len(v) > 1 and k not in self.time_fields: 484 505 if k not in custom_fields: 485 506 col = 't.' + k 486 507 else: … … 501 522 else: 502 523 clauses.append("(" + " OR ".join( 503 524 [item[0] for item in constraint_sql]) + ")") 504 args += [item[1] for item in constraint_sql] 525 for item in constraint_sql: 526 args.extend(item[1]) 505 527 elif len(v) == 1: 506 528 constraint_sql = get_constraint_sql(k, v[0], mode, neg) 507 529 if constraint_sql: 508 530 clauses.append(constraint_sql[0]) 509 args. append(constraint_sql[1])531 args.extend(constraint_sql[1]) 510 532 511 533 clauses = filter(None, clauses) 512 534 if clauses: … … 530 552 # FIXME: This is a somewhat ugly hack. Can we also have the 531 553 # column type for this? If it's an integer, we do first 532 554 # one, if text, we do 'else' 533 if name in ('id', 'time', 'changetime'):555 if name == 'id' or name in self.time_fields: 534 556 sql.append("COALESCE(%s,0)=0%s," % (col, desc)) 535 557 else: 536 558 sql.append("COALESCE(%s,'')=''%s," % (col, desc)) … … 575 597 cols = self.get_columns() 576 598 labels = dict([(f['name'], f['label']) for f in self.fields]) 577 599 578 # TODO: remove after adding time/changetime to the api.py579 labels['changetime'] = _('Modified')580 labels['time'] = _('Created')581 582 600 headers = [{ 583 601 'name': col, 'label': labels.get(col, _('Ticket')), 584 602 'href': self.get_href(context.href, order=col, … … 608 626 modes['select'] = [ 609 627 {'name': _("is"), 'value': ""}, 610 628 {'name': _("is not"), 'value': "!"} 629 ] 630 modes['time'] = [ 631 {'name': _("is between"), 'value': ""}, 632 {'name': _("is not between"), 'value': "!"}, 611 633 ] 612 634 613 635 groups = {} … … 826 848 827 849 def _get_constraints(self, req): 828 850 constraints = {} 829 ticket_fields = [f['name'] for f in 830 TicketSystem(self.env).get_ticket_fields()] 851 fields = TicketSystem(self.env).get_ticket_fields() 852 synonyms = TicketSystem(self.env).get_field_synonyms() 853 ticket_fields = [f['name'] for f in fields] 831 854 ticket_fields.append('id') 855 ticket_fields.extend(synonyms.iterkeys()) 856 time_fields = [f['name'] for f in fields if f['type'] == 'time'] 857 time_fields.extend([k for (k, v) in synonyms.iteritems() 858 if v in time_fields]) 832 859 833 860 # For clients without JavaScript, we remove constraints here if 834 861 # requested … … 850 877 mode = req.args.get(field + '_mode') 851 878 if mode: 852 879 vals = [mode + x for x in vals] 880 if field in time_fields: 881 ends = req.args.getlist(field + '_end') 882 if ends: 883 vals = [start + ';' + end 884 for (start, end) in zip(vals, ends)] 853 885 if field in remove_constraints: 854 886 idx = remove_constraints[field] 855 887 if idx >= 0: … … 858 890 continue 859 891 else: 860 892 continue 861 constraints[field] = vals 893 constraints.setdefault(synonyms.get(field, field), 894 []).extend(vals) 862 895 863 896 return constraints 864 897 … … 954 987 if col in ('cc', 'reporter'): 955 988 value = Chrome(self.env).format_emails(context(ticket), 956 989 value) 990 elif col in query.time_fields: 991 value = format_datetime(value, tzinfo=req.tz) 957 992 values.append(unicode(value).encode('utf-8')) 958 993 writer.writerow(values) 959 994 return (content.getvalue(), '%s;charset=utf-8' % mimetype) -
trac/ticket/templates/query.html
diff --git a/trac/ticket/templates/query.html b/trac/ticket/templates/query.html
a b 43 43 <py:for each="field_name in field_names" py:with="field = fields[field_name]"> 44 44 <py:for each="constraint_name, constraint in constraints.items()"> 45 45 <tbody py:if="field_name == constraint_name" 46 py:with="multiline = field.type in ('select', 'text', 'textarea' )">46 py:with="multiline = field.type in ('select', 'text', 'textarea', 'time')"> 47 47 <py:for each="constraint_idx, constraint_value in enumerate(constraint['values'])"> 48 48 <tr class="${field_name}" py:if="multiline or constraint_idx == 0"> 49 49 <py:choose test="constraint_idx"> … … 95 95 96 96 <py:when test="field.type in ('text', 'textarea')"> 97 97 <input type="text" name="${field_name}" value="$constraint_value" size="42" /> 98 </py:when> 99 100 <py:when test="'time'" py:with="(start, end) = ';' in constraint_value 101 and constraint_value.split(';', 1) 102 or (constraint_value, '')"> 103 <input type="text" name="${field_name}" value="$start" size="14" /> 104 <label>and</label> 105 <input type="text" name="${field_name}_end" value="$end" size="14" /> 98 106 </py:when> 99 107 100 108 </td> -
trac/ticket/templates/ticket.html
diff --git a/trac/ticket/templates/ticket.html b/trac/ticket/templates/ticket.html
a b 125 125 <div id="ticket" py:if="ticket.exists or preview_mode" 126 126 class="${preview_mode and 'ticketdraft' or None}"> 127 127 <div class="date"> 128 <p py:if="ticket.exists">Opened ${dateinfo(ticket.time _created)} ago</p>129 <p py:if="ticket. time_changed != ticket.time_created">Last modified ${dateinfo(ticket.time_changed)} ago</p>128 <p py:if="ticket.exists">Opened ${dateinfo(ticket.time)} ago</p> 129 <p py:if="ticket.changetime != ticket.time">Last modified ${dateinfo(ticket.changetime)} ago</p> 130 130 <p py:if="not ticket.exists"><i>(ticket not yet created)</i></p> 131 131 </div> 132 132 <!-- use a placeholder if it's a new ticket --> -
trac/ticket/tests/notification.py
diff --git a/trac/ticket/tests/notification.py b/trac/ticket/tests/notification.py
a b 605 605 # note project title / URL are not validated yet 606 606 607 607 # ticket properties which are not expected in the banner 608 xlist = ['summary', 'description', 'link', 'comment', 'new'] 608 xlist = ['summary', 'description', 'link', 'comment', 'new', 609 'time', 'changetime'] 609 610 # check banner content (field exists, msg value matches ticket value) 610 611 for p in [prop for prop in ticket.values.keys() if prop not in xlist]: 611 612 self.failIf(not props.has_key(p)) -
trac/ticket/tests/query.py
diff --git a/trac/ticket/tests/query.py b/trac/ticket/tests/query.py
a b 2 2 from trac.mimeview import Context 3 3 from trac.test import Mock, EnvironmentStub, MockPerm 4 4 from trac.ticket.query import Query, QueryModule 5 from trac.util.datefmt import utc 5 6 from trac.web.href import Href 6 7 from trac.wiki.formatter import LinkFormatter 7 8 from trac.db.sqlite_backend import sqlite_version … … 35 36 36 37 def setUp(self): 37 38 self.env = EnvironmentStub(default_data=True) 38 self.req = Mock(href=self.env.href, authname='anonymous' )39 self.req = Mock(href=self.env.href, authname='anonymous', tz=utc) 39 40 40 41 41 42 def test_all_ordered_by_id(self): … … 336 337 self.assertEqual([], args) 337 338 tickets = query.execute(self.req) 338 339 340 def test_constrained_by_time_range(self): 341 query = Query.from_string(self.env, 'created=2008-08-01;2008-09-01', order='id') 342 sql, args = query.get_sql(self.req) 343 self.assertEqualSQL(sql, 344 """SELECT t.id AS id,t.summary AS summary,t.time AS time,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.changetime AS changetime,priority.value AS priority_value 345 FROM ticket AS t 346 LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 347 WHERE (CAST(t.time AS int)>=%s AND CAST(t.time AS int)<%s) 348 ORDER BY COALESCE(t.id,0)=0,t.id""") 349 self.assertEqual([1217548800, 1220227200], args) 350 tickets = query.execute(self.req) 351 352 def test_constrained_by_time_range_exclusion(self): 353 query = Query.from_string(self.env, 'created!=2008-08-01;2008-09-01', order='id') 354 sql, args = query.get_sql(self.req) 355 self.assertEqualSQL(sql, 356 """SELECT t.id AS id,t.summary AS summary,t.time AS time,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.changetime AS changetime,priority.value AS priority_value 357 FROM ticket AS t 358 LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 359 WHERE NOT (CAST(t.time AS int)>=%s AND CAST(t.time AS int)<%s) 360 ORDER BY COALESCE(t.id,0)=0,t.id""") 361 self.assertEqual([1217548800, 1220227200], args) 362 tickets = query.execute(self.req) 363 364 def test_constrained_by_time_range_open_right(self): 365 query = Query.from_string(self.env, 'created=2008-08-01;', order='id') 366 sql, args = query.get_sql(self.req) 367 self.assertEqualSQL(sql, 368 """SELECT t.id AS id,t.summary AS summary,t.time AS time,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.changetime AS changetime,priority.value AS priority_value 369 FROM ticket AS t 370 LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 371 WHERE CAST(t.time AS int)>=%s 372 ORDER BY COALESCE(t.id,0)=0,t.id""") 373 self.assertEqual([1217548800], args) 374 tickets = query.execute(self.req) 375 376 def test_constrained_by_time_range_open_left(self): 377 query = Query.from_string(self.env, 'created=;2008-09-01', order='id') 378 sql, args = query.get_sql(self.req) 379 self.assertEqualSQL(sql, 380 """SELECT t.id AS id,t.summary AS summary,t.time AS time,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.changetime AS changetime,priority.value AS priority_value 381 FROM ticket AS t 382 LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 383 WHERE CAST(t.time AS int)<%s 384 ORDER BY COALESCE(t.id,0)=0,t.id""") 385 self.assertEqual([1220227200], args) 386 tickets = query.execute(self.req) 387 388 def test_constrained_by_time_range_modified(self): 389 query = Query.from_string(self.env, 'modified=2008-08-01;2008-09-01', order='id') 390 sql, args = query.get_sql(self.req) 391 self.assertEqualSQL(sql, 392 """SELECT t.id AS id,t.summary AS summary,t.changetime AS changetime,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.time AS time,priority.value AS priority_value 393 FROM ticket AS t 394 LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 395 WHERE (CAST(t.changetime AS int)>=%s AND CAST(t.changetime AS int)<%s) 396 ORDER BY COALESCE(t.id,0)=0,t.id""") 397 self.assertEqu
