Ticket #2288: 2288-time-queries-r7499.2.patch
| File 2288-time-queries-r7499.2.patch, 34.4 KB (added by rblank, 3 years 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") { 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 … … 156 152 elif field in kw_bools: 157 153 kw[field] = True 158 154 elif field == 'col': 159 cols.extend(processed_values) 155 cols.extend(synonyms.get(value, value) 156 for value in processed_values) 160 157 else: 161 constraints[field] = processed_values 158 constraints.setdefault(synonyms.get(field, field), 159 []).extend(processed_values) 162 160 except UnicodeError: 163 161 pass # field must be a str, see `get_href()` 164 162 report = constraints.pop('report', None) … … 184 182 if col in cols: 185 183 cols.remove(col) 186 184 cols.append(col) 187 # TODO: fix after adding time/changetime to the api.py188 cols += ['time', 'changetime']189 185 190 186 # Semi-intelligently remove columns that are restricted to a single 191 187 # value by a query constraint. … … 194 190 constraint = self.constraints[col] 195 191 if len(constraint) == 1 and constraint[0] \ 196 192 and not constraint[0][0] in ('!', '~', '^', '$'): 197 if col in cols :193 if col in cols and col not in self.time_fields: 198 194 cols.remove(col) 199 195 if col == 'status' and not 'closed' in constraint \ 200 196 and 'resolution' in cols: … … 298 294 result['href'] = req.href.ticket(val) 299 295 elif val is None: 300 296 val = '--' 301 elif name in ('changetime', 'time'):297 elif name in self.time_fields: 302 298 val = datetime.fromtimestamp(int(val or 0), utc) 303 299 elif field and field['type'] == 'checkbox': 304 300 try: … … 424 420 425 421 def get_constraint_sql(name, value, mode, neg): 426 422 if name not in custom_fields: 427 name= 't.' + name423 col = 't.' + name 428 424 else: 429 name= name + '.value'425 col = name + '.value' 430 426 value = value[len(mode) + neg:] 431 427 428 if name in self.time_fields: 429 if ';' in value: 430 (start, end) = [each.strip() for each in 431 value.split(';', 1)] 432 else: 433 (start, end) = (value.strip(), '') 434 col_cast = db.cast(col, 'int') 435 if start and end: 436 start = to_timestamp(parse_date(start, req.tz)) 437 end = to_timestamp(parse_date(end, req.tz)) 438 return ("%s(%s>=%%s AND %s<%%s)" % (neg and 'NOT ' or '', 439 col_cast, col_cast), 440 (start, end)) 441 elif start: 442 start = to_timestamp(parse_date(start, req.tz)) 443 return ("%s%s>=%%s" % (neg and 'NOT ' or '', col_cast), 444 (start, )) 445 elif end: 446 end = to_timestamp(parse_date(end, req.tz)) 447 return ("%s%s<%%s" % (neg and 'NOT ' or '', col_cast), 448 (end, )) 449 else: 450 return None 451 432 452 if mode == '': 433 return ("COALESCE(%s,'')%s=%%s" % (name, neg and '!' or ''), 434 value) 453 return ("COALESCE(%s,'')%s=%%s" % (col, neg and '!' or ''), 454 (value, )) 455 435 456 if not value: 436 457 return None 437 db = self.env.get_db_cnx()438 458 value = db.like_escape(value) 439 459 if mode == '~': 440 460 value = '%' + value + '%' … … 442 462 value = value + '%' 443 463 elif mode == '$': 444 464 value = '%' + value 445 return ("COALESCE(%s,'') %s%s" % ( name, neg and 'NOT ' or '',465 return ("COALESCE(%s,'') %s%s" % (col, neg and 'NOT ' or '', 446 466 db.like()), 447 value)467 (value, )) 448 468 469 db = self.env.get_db_cnx() 449 470 clauses = [] 450 471 args = [] 451 472 for k, v in self.constraints.items(): … … 479 500 clauses.append('%s(%s)' % (neg and 'NOT ' or '', 480 501 ' OR '.join(id_clauses))) 481 502 # Special case for exact matches on multiple values 482 elif not mode and len(v) > 1 :503 elif not mode and len(v) > 1 and k not in self.time_fields: 483 504 if k not in custom_fields: 484 505 col = 't.' + k 485 506 else: … … 500 521 else: 501 522 clauses.append("(" + " OR ".join( 502 523 [item[0] for item in constraint_sql]) + ")") 503 args += [item[1] for item in constraint_sql] 524 for item in constraint_sql: 525 args.extend(item[1]) 504 526 elif len(v) == 1: 505 527 constraint_sql = get_constraint_sql(k, v[0], mode, neg) 506 528 if constraint_sql: 507 529 clauses.append(constraint_sql[0]) 508 args. append(constraint_sql[1])530 args.extend(constraint_sql[1]) 509 531 510 532 clauses = filter(None, clauses) 511 533 if clauses or cached_ids: … … 531 553 # FIXME: This is a somewhat ugly hack. Can we also have the 532 554 # column type for this? If it's an integer, we do first 533 555 # one, if text, we do 'else' 534 if name in ('id', 'time', 'changetime'):556 if name == 'id' or name in self.time_fields: 535 557 sql.append("COALESCE(%s,0)=0%s," % (col, desc)) 536 558 else: 537 559 sql.append("COALESCE(%s,'')=''%s," % (col, desc)) … … 576 598 cols = self.get_columns() 577 599 labels = dict([(f['name'], f['label']) for f in self.fields]) 578 600 579 # TODO: remove after adding time/changetime to the api.py580 labels['changetime'] = _('Modified')581 labels['time'] = _('Created')582 583 601 headers = [{ 584 602 'name': col, 'label': labels.get(col, _('Ticket')), 585 603 'href': self.get_href(context.href, order=col, … … 607 625 modes['select'] = [ 608 626 {'name': _("is"), 'value': ""}, 609 627 {'name': _("is not"), 'value': "!"} 628 ] 629 modes['time'] = [ 630 {'name': _("is between"), 'value': ""}, 631 {'name': _("is not between"), 'value': "!"}, 610 632 ] 611 633 612 634 groups = {} … … 807 829 808 830 def _get_constraints(self, req): 809 831 constraints = {} 810 ticket_fields = [f['name'] for f in 811 TicketSystem(self.env).get_ticket_fields()] 832 fields = TicketSystem(self.env).get_ticket_fields() 833 synonyms = TicketSystem(self.env).get_field_synonyms() 834 ticket_fields = [f['name'] for f in fields] 812 835 ticket_fields.append('id') 836 ticket_fields.extend(synonyms.iterkeys()) 837 time_fields = [f['name'] for f in fields if f['type'] == 'time'] 838 time_fields.extend([k for (k, v) in synonyms.iteritems() 839 if v in time_fields]) 813 840 814 841 # For clients without JavaScript, we remove constraints here if 815 842 # requested … … 831 858 mode = req.args.get(field + '_mode') 832 859 if mode: 833 860 vals = [mode + x for x in vals] 861 if field in time_fields: 862 ends = req.args.getlist(field + '_end') 863 if ends: 864 vals = [start + ';' + end 865 for (start, end) in zip(vals, ends)] 834 866 if field in remove_constraints: 835 867 idx = remove_constraints[field] 836 868 if idx >= 0: … … 839 871 continue 840 872 else: 841 873 continue 842 constraints[field] = vals 874 constraints.setdefault(synonyms.get(field, field), 875 []).extend(vals) 843 876 844 877 return constraints 845 878 … … 935 968 if col in ('cc', 'reporter'): 936 969 value = Chrome(self.env).format_emails(context(ticket), 937 970 value) 971 elif col in query.time_fields: 972 value = format_datetime(value, tzinfo=req.tz) 938 973 values.append(unicode(value).encode('utf-8')) 939 974 writer.writerow(values) 940 975 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 42 42 <py:for each="field_name, field in fields.items()"> 43 43 <py:for each="constraint_name, constraint in constraints.items()"> 44 44 <tbody py:if="field_name == constraint_name" 45 py:with="multiline = field.type in ('select', 'text' )">45 py:with="multiline = field.type in ('select', 'text', 'time')"> 46 46 <py:for each="constraint_idx, constraint_value in enumerate(constraint['values'])"> 47 47 <tr class="${field_name}" py:if="multiline or constraint_idx == 0"> 48 48 <py:choose test="constraint_idx"> … … 94 94 95 95 <py:when test="'text'"> 96 96 <input type="text" name="${field_name}" value="$constraint_value" size="42" /> 97 </py:when> 98 99 <py:when test="'time'" py:with="(start, end) = ';' in constraint_value 100 and constraint_value.split(';', 1) 101 or (constraint_value, '')"> 102 <input type="text" name="${field_name}" value="$start" size="14" /> 103 <label>and</label> 104 <input type="text" name="${field_name}_end" value="$end" size="14" /> 97 105 </py:when> 98 106 99 107 </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.assertEqual([1217548800, 1220227200], args) 398 tickets = query.execute(self.req) 399 339 400 def test_csv_escape(self): 340 401 query = Mock(get_columns=lambda: ['col1'], 341 402 execute=lambda r,c: [{'id': 1, 342 'col1': 'value, needs escaped'}]) 403 'col1': 'value, needs escaped'}], 404 time_fields=['time', 'changetime']) 343 405 content, mimetype = QueryModule(self.env).export_csv( 344 406 Mock(href=self.env.href, perm=MockPerm()), 345 407 query) -
trac/ticket/web_ui.py
diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
a b 39 39 from trac.timeline.api import ITimelineEventProvider 40 40 from trac.util import get_reporter_id, partition 41 41 from trac.util.compat import any 42 from trac.util.datefmt import to_timestamp, utc42 from trac.util.datefmt import format_datetime, to_timestamp, utc 43 43 from trac.util.text import CRLF, shorten_line, obfuscate_email_address 44 44 from trac.util.presentation import separated 45 45 from trac.util.translation import _, tag_, N_, gettext … … 520 520 'reassign_owner': req.authname, 521 521 'resolve_resolution': None, 522 522 # Store a timestamp for detecting "mid air collisions" 523 'timestamp': str(ticket .time_changed)})523 'timestamp': str(ticket['changetime'])}) 524 524 525 525 self._insert_ticket_data(req, ticket, data, 526 526 get_reporter_id(req, 'author'), field_changes) … … 653 653 history = [c for c in history if any([f in text_fields 654 654 for f in c['fields']])] 655 655 history.append({'version': 0, 'comment': "''Initial version''", 656 'date': ticket .time_created,656 'date': ticket['time'], 657 657 'author': ticket['reporter'] # not 100% accurate... 658 658 }) 659 659 data.update({'title': _('Ticket History'), … … 825 825 def export_csv(self, req, ticket, sep=',', mimetype='text/plain'): 826 826 # FIXME: consider dumping history of changes here as well 827 827 # as one row of output doesn't seem to be terribly useful... 828 fields = [f for f in ticket.fields 829 if f['name'] not in ('time', 'changetime')] 828 830 content = StringIO() 829 831 writer = csv.writer(content, delimiter=sep, quoting=csv.QUOTE_MINIMAL) 830 writer.writerow(['id'] + [unicode(f['name']) for f in ticket.fields])832 writer.writerow(['id'] + [unicode(f['name']) for f in fields]) 831 833 832 834 context = Context.from_request(req, ticket.resource) 833 835 cols = [unicode(ticket.id)] 834 for f in ticket.fields:836 for f in fields: 835 837 name = f['name'] 836 838 value = ticket.values.get(name, '') 837 839 if name in ('cc', 'reporter'): 838 840 value = Chrome(self.env).format_emails(context, value, ' ') 841 elif name in ticket.time_fields: 842 value = format_datetime(value, tzinfo=req.tz) 839 843 cols.append(value.encode('utf-8')) 840 844 writer.writerow(cols) 841 845 return (content.getvalue(), '%s;charset=utf-8' % mimetype) … … 901 905 902 906 # Mid air collision? 903 907 if ticket.exists and (ticket._old or comment): 904 if req.args.get('ts') != str(ticket .time_changed):908 if req.args.get('ts') != str(ticket['changetime']): 905 909 add_warning(req, _("Sorry, can not save your changes. " 906 910 "This ticket has been modified by someone else " 907 911 "since you started")) … … 1085 1089 1086 1090 # per field settings 1087 1091 if name in ('summary', 'reporter', 'description', 'status', 1088 'resolution' ):1092 'resolution', 'time', 'changetime'): 1089 1093 field['skip'] = True 1090 1094 elif name == 'owner': 1091 1095 field['skip'] = True -
trac/util/datefmt.py
diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py
a b 172 172 173 173 def parse_date(text, tzinfo=None): 174 174 tzinfo = tzinfo or localtz 175 if text == 'now': # TODO: today, yesterday, etc. 176 return datetime.now(utc) 177 tm = None 175 dt = None 178 176 text = text.strip() 179 177 # normalize ISO time 180 178 match = _ISO_8601_RE.match(text) … … 198 196 tm = time.strptime('%s ' * 6 % (years, months, days, 199 197 hours, minutes, seconds), 200 198 '%Y %m %d %H %M %S ') 199 dt = datetime(*(tm[0:6] + (0, tzinfo))) 201 200 except ValueError: 202 201 pass 203 else:202 if dt is None: 204 203 for format in ['%x %X', '%x, %X', '%X %x', '%X, %x', '%x', '%c', 205 204 '%b %d, %Y']: 206 205 try: 207 206 tm = time.strptime(text, format) 207 dt = datetime(*(tm[0:6] + (0, tzinfo))) 208 208 break 209 209 except ValueError: 210 210 continue 211 if tm == None: 211 if dt is None: 212 dt = _parse_relative_time(text, tzinfo) 213 if dt is None: 212 214 hint = get_date_format_hint() 213 215 raise TracError('"%s" is an invalid date, or the date format ' 214 216 'is not known. Try "%s" instead.' % (text, hint), 215 217 'Invalid Date') 216 dt = datetime(*(tm[0:6] + (0, tzinfo)))217 218 # Make sure we can convert it to a timestamp and back - fromtimestamp() 218 219 # may raise ValueError if larger than platform C localtime() or gmtime() 219 220 try: … … 223 224 'Try a date closer to present time.' % (text,), 224 225 'Invalid Date') 225 226 return dt 227 228 229 _REL_TIME_RE = re.compile( 230 r'(\d+\.?\d*)\s*' 231 r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*' 232 r'(?:ago)?$') 233 _time_intervals = dict( 234 second=lambda v: timedelta(seconds=v), 235 minute=lambda v: timedelta(minutes=v), 236 hour=lambda v: timedelta(hours=v), 237 day=lambda v: timedelta(days=v), 238 week=lambda v: timedelta(weeks=v), 239 month=lambda v: timedelta(days=30 * v), 240 year=lambda v: timedelta(days=365 * v), 241 h=lambda v: timedelta(hours=v), 242 d=lambda v: timedelta(days=v), 243 w=lambda v: timedelta(weeks=v), 244 m=lambda v: timedelta(days=30 * v), 245 y=lambda v: timedelta(days=365 * v), 246 ) 247 _TIME_START_RE = re.compile(r'(this|last)\s*' 248 r'(second|minute|hour|day|week|month|year)$') 249 _time_starts = dict( 250 second=lambda now: now.replace(microsecond=0), 251 minute=lambda now: now.replace(microsecond=0, second=0), 252 hour=lambda now: now.replace(microsecond=0, second=0, minute=0), 253 day=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0), 254 week=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0) \ 255 - timedelta(days=now.weekday()), 256 month=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0, 257 day=1), 258 year=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0, 259 day=1, month=1), 260 ) 261 262 def _parse_relative_time(text, tzinfo): 263 now = datetime.now(tzinfo) 264 if text == 'now': 265 return now 266 if text == 'today': 267 return now.replace(microsecond=0, second=0, minute=0, hour=0) 268 if text == 'yesterday': 269 return now.replace(microsecond=0, second=0, minute=0, hour=0) \ 270 - timedelta(days=1) 271 match = _REL_TIME_RE.match(text) 272 if match: 273 (value, interval) = match.groups() 274 return now - _time_intervals[interval](float(value)) 275 match = _TIME_START_RE.match(text) 276 if match: 277 (which, start) = match.groups() 278 dt = _time_starts[start](now) 279 if which == 'last': 280 if start == 'month': 281 if dt.month > 1: 282 dt = dt.replace(month=dt.month - 1) 283 else: 284 dt = dt.replace(year=dt.year - 1, month=12) 285 else: 286 dt -= _time_intervals[start](1) 287 return dt 288 return None 226 289 227 290 228 291 # -- timezone utilities
