Ticket #2288: 2288-time-queries-r7578.patch
| File 2288-time-queries-r7578.patch, 41.2 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 }41 #filters td.filter label.control { padding-right: 1em } 42 42 #filters td.actions { text-align: right; white-space: nowrap } 43 43 44 44 #columns div label { -
trac/htdocs/js/query.js
diff --git a/trac/htdocs/js/query.js b/trac/htdocs/js/query.js
a b 16 16 // Removes an existing row from the filters table 17 17 function removeRow(button, propertyName) { 18 18 var tr = getAncestorByTagName(button, "tr"); 19 20 var mode = null; 21 var selects = tr.getElementsByTagName("select"); 22 for (var i = 0; i < selects.length; i++) { 23 if (selects[i].name == propertyName + "_mode") { 24 mode = selects[i]; 25 break; 26 } 27 } 28 if (mode && (getAncestorByTagName(mode, "tr") == tr)) { 19 var label = document.getElementById("label_" + propertyName); 20 if (label && (getAncestorByTagName(label, "tr") == tr)) { 29 21 // Check whether there are more 'or' rows for this filter 30 22 var next = $(tr).next()[0]; 31 23 if (next && (next.className == propertyName)) { … … 42 34 43 35 var thisTh = getChildElementAt(tr, 0); 44 36 var nextTh = getChildElementAt(next, 0); 45 next.insertBefore(thisTh, nextTh); 46 nextTh.colSpan = 1; 47 48 thisTd = getChildElementAt(tr, 0); 49 nextTd = getChildElementAt(next, 1); 50 next.replaceChild(thisTd, nextTd); 37 if (nextTh.colSpan == 1) { 38 next.replaceChild(thisTh, nextTh); 39 } else { 40 next.insertBefore(thisTh, nextTh); 41 nextTh.colSpan = 1; 42 thisTd = getChildElementAt(tr, 0); 43 nextTd = getChildElementAt(next, 1); 44 next.replaceChild(thisTd, nextTd); 45 } 51 46 } 52 47 } 53 48 … … 117 112 function createLabel(text, htmlFor) { 118 113 var label = document.createElement("label"); 119 114 if (text) label.appendChild(document.createTextNode(text)); 120 if (htmlFor) label.htmlFor = htmlFor; 115 if (htmlFor) { 116 label.htmlFor = htmlFor; 117 label.className = "control"; 118 } 121 119 return label; 122 120 } 123 121 122 // Convenience function for creating an <input type="text"> 123 function createText(name, size) { 124 var input = document.createElement("input"); 125 input.type = "text"; 126 if (name) input.name = name; 127 if (size) input.size = size; 128 return input; 129 } 130 124 131 // Convenience function for creating an <input type="checkbox"> 125 132 function createCheckbox(name, value, id) { 126 133 var input = document.createElement("input"); … … 182 189 var th = document.createElement("th"); 183 190 th.scope = "row"; 184 191 if (!alreadyPresent) { 185 th.appendChild(createLabel(property.label)); 192 var label = createLabel(property.label); 193 label.id = "label_" + propertyName; 194 th.appendChild(label); 186 195 } else { 187 th.colSpan = 2;196 th.colSpan = property.type == "time"? 1: 2; 188 197 th.appendChild(createLabel("or")); 189 198 } 190 199 tr.appendChild(th); 191 200 192 201 var td = document.createElement("td"); 193 if (property.type == "radio" || property.type == "checkbox") { 202 var focusElement = null; 203 if (property.type == "radio" || property.type == "checkbox" || property.type == "time") { 194 204 td.colSpan = 2; 195 205 td.className = "filter"; 196 206 if (property.type == "radio") { … … 201 211 td.appendChild(createLabel(option ? option : "none", 202 212 propertyName + "_" + option)); 203 213 } 204 } else {214 } else if (property.type == "checkbox") { 205 215 td.appendChild(createRadio(propertyName, "1", propertyName + "_on")); 206 216 td.appendChild(document.createTextNode(" ")); 207 217 td.appendChild(createLabel("yes", propertyName + "_on")); 208 218 td.appendChild(createRadio(propertyName, "0", propertyName + "_off")); 209 219 td.appendChild(document.createTextNode(" ")); 210 220 td.appendChild(createLabel("no", propertyName + "_off")); 221 } else if (property.type == "time") { 222 td.appendChild(createLabel("between")); 223 td.appendChild(document.createTextNode(" ")); 224 focusElement = createText(propertyName, 14); 225 td.appendChild(focusElement); 226 td.appendChild(document.createTextNode(" ")); 227 td.appendChild(createLabel("and")); 228 td.appendChild(document.createTextNode(" ")); 229 td.appendChild(createText(propertyName + "_end", 14)); 211 230 } 212 231 tr.appendChild(td); 213 232 } else { … … 224 243 td = document.createElement("td"); 225 244 td.className = "filter"; 226 245 if (property.type == "select") { 227 var element = createSelect(propertyName, property.options, true);246 focusElement = createSelect(propertyName, property.options, true); 228 247 } else if ((property.type == "text") || (property.type == "textarea")) { 229 var element = document.createElement("input"); 230 element.type = "text"; 231 element.name = propertyName; 232 element.size = 42; 248 focusElement = createText(propertyName, 42); 233 249 } 234 td.appendChild(element); 235 element.focus(); 250 td.appendChild(focusElement); 236 251 tr.appendChild(td); 237 252 } 238 253 … … 266 281 tbody.appendChild(tr); 267 282 insertionPoint.parentNode.insertBefore(tbody, insertionPoint); 268 283 } 284 if(focusElement) 285 focusElement.focus(); 269 286 270 287 // Disable the add filter in the drop-down list 271 288 if (property.type == "radio" || property.type == "checkbox") { -
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)', … … 281 287 282 288 fields.sort(lambda x, y: cmp(x['order'], y['order'])) 283 289 return fields 290 291 def get_field_synonyms(self): 292 """Return a mapping from field name synonyms to field names. 293 The synonyms are supposed to be more intuitive for custom queries.""" 294 return {'created': 'time', 'modified': 'changetime'} 284 295 285 296 # IPermissionRequestor methods 286 297 -
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: … … 104 110 for i in range(len(std_fields)): 105 111 value = row[i] 106 112 if value is not None: 107 self.values[std_fields[i]] = row[i] 108 self.time_created = datetime.fromtimestamp(row[len(std_fields)], utc) 109 self.time_changed = datetime.fromtimestamp(row[len(std_fields) + 1], utc) 113 field = std_fields[i] 114 if field in self.time_fields: 115 self.values[field] = datetime.fromtimestamp(value, utc) 116 else: 117 self.values[field] = value 110 118 111 119 # Fetch custom fields if available 112 120 custom_fields = [f['name'] for f in self.fields if f.get('custom')] … … 166 174 # Add a timestamp 167 175 if when is None: 168 176 when = datetime.now(utc) 169 self. time_created = self.time_changed= when177 self.values['time'] = self.values['changetime'] = when 170 178 171 179 cursor = db.cursor() 172 180 … … 180 188 # No such component exists 181 189 pass 182 190 191 # Perform type conversions 192 values = dict(self.values) 193 for field in self.time_fields: 194 if field in values: 195 values[field] = to_timestamp(values[field]) 196 183 197 # Insert ticket record 184 created = to_timestamp(self.time_created)185 changed = to_timestamp(self.time_changed)186 198 std_fields = [] 187 199 custom_fields = [] 188 200 for f in self.fields: … … 192 204 custom_fields.append(fname) 193 205 else: 194 206 std_fields.append(fname) 195 cursor.execute("INSERT INTO ticket (%s ,time,changetime) VALUES (%s)"207 cursor.execute("INSERT INTO ticket (%s) VALUES (%s)" 196 208 % (','.join(std_fields), 197 ','.join(['%s'] * (len(std_fields) + 2))),198 [ self[name] for name in std_fields] + [created, changed])209 ','.join(['%s'] * len(std_fields))), 210 [values[name] for name in std_fields]) 199 211 tkt_id = db.get_last_id(cursor, 'ticket') 200 212 201 213 # Insert custom fields … … 291 303 db.commit() 292 304 old_values = self._old 293 305 self._old = {} 294 self. time_changed= when306 self.values['changetime'] = when 295 307 296 308 for listener in TicketSystem(self.env).change_listeners: 297 309 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)) … … 576 598 labels = dict([(f['name'], f['label']) for f in self.fields]) 577 599 wikify = set(f['name'] for f in self.fields 578 600 if f['type'] == 'text' and f.get('format') == 'wiki') 579 580 # TODO: remove after adding time/changetime to the api.py581 labels['changetime'] = _('Modified')582 labels['time'] = _('Created')583 601 584 602 headers = [{ 585 603 'name': col, 'label': labels.get(col, _('Ticket')), … … 829 847 830 848 def _get_constraints(self, req): 831 849 constraints = {} 832 ticket_fields = [f['name'] for f in 833 TicketSystem(self.env).get_ticket_fields()] 850 fields = TicketSystem(self.env).get_ticket_fields() 851 synonyms = TicketSystem(self.env).get_field_synonyms() 852 ticket_fields = [f['name'] for f in fields] 834 853 ticket_fields.append('id') 854 ticket_fields.extend(synonyms.iterkeys()) 855 time_fields = [f['name'] for f in fields if f['type'] == 'time'] 856 time_fields.extend([k for (k, v) in synonyms.iteritems() 857 if v in time_fields]) 835 858 836 859 # For clients without JavaScript, we remove constraints here if 837 860 # requested … … 853 876 mode = req.args.get(field + '_mode') 854 877 if mode: 855 878 vals = [mode + x for x in vals] 879 if field in time_fields: 880 ends = req.args.getlist(field + '_end') 881 if ends: 882 vals = [start + ';' + end 883 for (start, end) in zip(vals, ends)] 856 884 if field in remove_constraints: 857 885 idx = remove_constraints[field] 858 886 if idx >= 0: … … 861 889 continue 862 890 else: 863 891 continue 864 constraints[field] = vals 892 constraints.setdefault(synonyms.get(field, field), 893 []).extend(vals) 865 894 866 895 return constraints 867 896 … … 957 986 if col in ('cc', 'reporter'): 958 987 value = Chrome(self.env).format_emails(context(ticket), 959 988 value) 989 elif col in query.time_fields: 990 value = format_datetime(value, tzinfo=req.tz) 960 991 values.append(unicode(value).encode('utf-8')) 961 992 writer.writerow(values) 962 993 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"> 50 50 <py:when test="0"> 51 <th scope="row"><label >$field.label</label></th>52 <td py:if="field.type not in ('radio', 'checkbox' )" class="mode">51 <th scope="row"><label id="label_${field_name}">$field.label</label></th> 52 <td py:if="field.type not in ('radio', 'checkbox', 'time')" class="mode"> 53 53 <select name="${field_name}_mode"> 54 54 <option py:for="mode in modes[field.type]" value="$mode.value" 55 55 selected="${mode.value == constraint.mode and 'selected' or None}">$mode.name … … 58 58 </td> 59 59 </py:when> 60 60 <py:otherwise><!--! not the first line of a multiline constraint --> 61 <th colspan=" 2"><label>or</label></th>61 <th colspan="${field.type == 'time' and 1 or 2}"><label>or</label></th> 62 62 </py:otherwise> 63 63 </py:choose> 64 64 65 <td class="filter" colspan="${field.type in ('radio', 'checkbox' ) and 2 or None}"65 <td class="filter" colspan="${field.type in ('radio', 'checkbox', 'time') and 2 or None}" 66 66 py:choose=""> 67 67 68 68 <py:when test="field.type == 'select'"> … … 80 80 value="$option" 81 81 checked="${any([(value == option) == (constraint.mode == '') 82 82 for value in constraint['values']]) and 'checked' or None}" /> 83 <label for="${field_name}_$option" >${option or 'none'}</label>83 <label for="${field_name}_$option" class="control">${option or 'none'}</label> 84 84 </py:for> 85 85 </py:when> 86 86 87 87 <py:when test="field.type == 'checkbox'"> 88 88 <input type="radio" id="${field_name}_on" name="$field_name" value="1" 89 89 checked="${constraint.mode != '!' or constraint_value == '1' or None}" /> 90 <label for="${field_name}_on" >yes</label>90 <label for="${field_name}_on" class="control">yes</label> 91 91 <input type="radio" id="${field_name}_off" name="$field_name" value="0" 92 92 checked="${constraint.mode == '!' or constraint_value != '1' or None}" /> 93 <label for="${field_name}_off" >no</label>93 <label for="${field_name}_off" class="control">no</label> 94 94 </py:when> 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 <label>between</label> 104 <input type="text" name="${field_name}" value="$start" size="14" /> 105 <label>and</label> 106 <input type="text" name="${field_name}_end" value="$end" size="14" /> 98 107 </py:when> 99 108 100 109 </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 … … 526 526 'reassign_owner': req.authname, 527 527 'resolve_resolution': None, 528 528 # Store a timestamp for detecting "mid air collisions" 529 'timestamp': str(ticket .time_changed)})529 'timestamp': str(ticket['changetime'])}) 530 530 531 531 self._insert_ticket_data(req, ticket, data, 532 532 get_reporter_id(req, 'author'), field_changes) … … 661 661 history = [c for c in history if any([f in text_fields 662 662 for f in c['fields']])] 663 663 history.append({'version': 0, 'comment': "''Initial version''", 664 'date': ticket .time_created,664 'date': ticket['time'], 665 665 'author': ticket['reporter'] # not 100% accurate... 666 666 }) 667 667 data.update({'title': _('Ticket History'), … … 833 833 def export_csv(self, req, ticket, sep=',', mimetype='text/plain'): 834 834 # FIXME: consider dumping history of changes here as well 835 835 # as one row of output doesn't seem to be terribly useful... 836 fields = [f for f in ticket.fields 837 if f['name'] not in ('time', 'changetime')] 836 838 content = StringIO() 837 839 writer = csv.writer(content, delimiter=sep, quoting=csv.QUOTE_MINIMAL) 838 writer.writerow(['id'] + [unicode(f['name']) for f in ticket.fields])840 writer.writerow(['id'] + [unicode(f['name']) for f in fields]) 839 841 840 842 context = Context.from_request(req, ticket.resource) 841 843 cols = [unicode(ticket.id)] 842 for f in ticket.fields:844 for f in fields: 843 845 name = f['name'] 844 846 value = ticket.values.get(name, '') 845 847 if name in ('cc', 'reporter'): 846 848 value = Chrome(self.env).format_emails(context, value, ' ') 849 elif name in ticket.time_fields: 850 value = format_datetime(value, tzinfo=req.tz) 847 851 cols.append(value.encode('utf-8')) 848 852 writer.writerow(cols) 849 853 return (content.getvalue(), '%s;charset=utf-8' % mimetype) … … 909 913 910 914 # Mid air collision? 911 915 if ticket.exists and (ticket._old or comment): 912 if req.args.get('ts') != str(ticket .time_changed):916 if req.args.get('ts') != str(ticket['changetime']): 913 917 add_warning(req, _("Sorry, can not save your changes. " 914 918 "This ticket has been modified by someone else " 915 919 "since you started")) … … 1111 1115 1112 1116 # per field settings 1113 1117 if name in ('summary', 'reporter', 'description', 'status', 1114 'resolution' ):1118 'resolution', 'time', 'changetime'): 1115 1119 field['skip'] = True 1116 1120 elif name == 'owner': 1117 1121 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
