Edgewall Software

Ticket #2288: 2288-time-queries-r7578.patch

File 2288-time-queries-r7578.patch, 41.2 KB (added by rblank, 3 years ago)

Removed mode selection, always use "between ... and ..."

  • trac/htdocs/css/report.css

    diff --git a/trac/htdocs/css/report.css b/trac/htdocs/css/report.css
    a b  
    3838#filters td label { font-size: 11px } 
    3939#filters td.mode { text-align: right } 
    4040#filters td.filter { width: 100% } 
    41 #filters td.filter label { padding-right: 1em } 
     41#filters td.filter label.control { padding-right: 1em } 
    4242#filters td.actions { text-align: right; white-space: nowrap } 
    4343 
    4444#columns div label {  
  • trac/htdocs/js/query.js

    diff --git a/trac/htdocs/js/query.js b/trac/htdocs/js/query.js
    a b  
    1616    // Removes an existing row from the filters table 
    1717    function removeRow(button, propertyName) { 
    1818      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)) { 
    2921        // Check whether there are more 'or' rows for this filter 
    3022        var next = $(tr).next()[0]; 
    3123        if (next && (next.className == propertyName)) { 
     
    4234   
    4335          var thisTh = getChildElementAt(tr, 0); 
    4436          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          } 
    5146        } 
    5247      } 
    5348   
     
    117112      function createLabel(text, htmlFor) { 
    118113        var label = document.createElement("label"); 
    119114        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        } 
    121119        return label; 
    122120      } 
    123121   
     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       
    124131      // Convenience function for creating an <input type="checkbox"> 
    125132      function createCheckbox(name, value, id) { 
    126133        var input = document.createElement("input"); 
     
    182189      var th = document.createElement("th"); 
    183190      th.scope = "row"; 
    184191      if (!alreadyPresent) { 
    185         th.appendChild(createLabel(property.label)); 
     192        var label = createLabel(property.label); 
     193        label.id = "label_" + propertyName; 
     194        th.appendChild(label); 
    186195      } else { 
    187         th.colSpan = 2; 
     196        th.colSpan = property.type == "time"? 1: 2; 
    188197        th.appendChild(createLabel("or")); 
    189198      } 
    190199      tr.appendChild(th); 
    191200   
    192201      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") { 
    194204        td.colSpan = 2; 
    195205        td.className = "filter"; 
    196206        if (property.type == "radio") { 
     
    201211            td.appendChild(createLabel(option ? option : "none", 
    202212              propertyName + "_" + option)); 
    203213          } 
    204         } else { 
     214        } else if (property.type == "checkbox") { 
    205215          td.appendChild(createRadio(propertyName, "1", propertyName + "_on")); 
    206216          td.appendChild(document.createTextNode(" ")); 
    207217          td.appendChild(createLabel("yes", propertyName + "_on")); 
    208218          td.appendChild(createRadio(propertyName, "0", propertyName + "_off")); 
    209219          td.appendChild(document.createTextNode(" ")); 
    210220          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)); 
    211230        } 
    212231        tr.appendChild(td); 
    213232      } else { 
     
    224243        td = document.createElement("td"); 
    225244        td.className = "filter"; 
    226245        if (property.type == "select") { 
    227           var element = createSelect(propertyName, property.options, true); 
     246          focusElement = createSelect(propertyName, property.options, true); 
    228247        } 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); 
    233249        } 
    234         td.appendChild(element); 
    235         element.focus(); 
     250        td.appendChild(focusElement); 
    236251        tr.appendChild(td); 
    237252      } 
    238253   
     
    266281        tbody.appendChild(tr); 
    267282        insertionPoint.parentNode.insertBefore(tbody, insertionPoint); 
    268283      } 
     284      if(focusElement) 
     285          focusElement.focus(); 
    269286   
    270287      // Disable the add filter in the drop-down list 
    271288      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  
    240240            field = {'name': name, 'type': 'text', 'label': name.title()} 
    241241            fields.append(field) 
    242242 
     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 
    243249        for field in self.get_custom_fields(): 
    244250            if field['name'] in [f['name'] for f in fields]: 
    245251                self.log.warning('Duplicate field name "%s" (ignoring)', 
     
    281287 
    282288        fields.sort(lambda x, y: cmp(x['order'], y['order'])) 
    283289        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'} 
    284295 
    285296    # IPermissionRequestor methods 
    286297 
  • trac/ticket/model.py

    diff --git a/trac/ticket/model.py b/trac/ticket/model.py
    a b  
    4040    def id_is_valid(num): 
    4141        return 0 < int(num) <= 1L << 31 
    4242 
     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     
    4347    def __init__(self, env, tkt_id=None, db=None, version=None): 
    4448        self.env = env 
    4549        self.resource = Resource('ticket', tkt_id, version) 
    4650        self.fields = TicketSystem(self.env).get_ticket_fields() 
     51        self.time_fields = [f['name'] for f in self.fields 
     52                            if f['type'] == 'time'] 
    4753        self.values = {} 
    4854        if tkt_id is not None: 
    4955            self._fetch_ticket(tkt_id, db) 
    5056        else: 
    5157            self._init_defaults(db) 
    52             self.id = self.time_created = self.time_changed = None 
     58            self.id = None 
    5359        self._old = {} 
    5460 
    5561    def _get_db(self, db): 
     
    6672    def _init_defaults(self, db=None): 
    6773        for field in self.fields: 
    6874            default = None 
    69             if field['name'] in ['resolution', 'status']: 
     75            if field['name'] in ['resolution', 'status', 'time', 'changetime']: 
    7076                # Ignore for new - only change through workflow 
    7177                pass 
    7278            elif not field.get('custom'): 
     
    9399            # Fetch the standard ticket fields 
    94100            std_fields = [f['name'] for f in self.fields if not f.get('custom')] 
    95101            cursor = db.cursor() 
    96             cursor.execute("SELECT %s,time,changetime FROM ticket WHERE id=%%s" 
     102            cursor.execute("SELECT %s FROM ticket WHERE id=%%s" 
    97103                           % ','.join(std_fields), (tkt_id,)) 
    98104            row = cursor.fetchone() 
    99105        if not row: 
     
    104110        for i in range(len(std_fields)): 
    105111            value = row[i] 
    106112            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 
    110118 
    111119        # Fetch custom fields if available 
    112120        custom_fields = [f['name'] for f in self.fields if f.get('custom')] 
     
    166174        # Add a timestamp 
    167175        if when is None: 
    168176            when = datetime.now(utc) 
    169         self.time_created = self.time_changed = when 
     177        self.values['time'] = self.values['changetime'] = when 
    170178 
    171179        cursor = db.cursor() 
    172180 
     
    180188                # No such component exists 
    181189                pass 
    182190 
     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         
    183197        # Insert ticket record 
    184         created = to_timestamp(self.time_created) 
    185         changed = to_timestamp(self.time_changed) 
    186198        std_fields = [] 
    187199        custom_fields = [] 
    188200        for f in self.fields: 
     
    192204                    custom_fields.append(fname) 
    193205                else: 
    194206                    std_fields.append(fname) 
    195         cursor.execute("INSERT INTO ticket (%s,time,changetime) VALUES (%s)" 
     207        cursor.execute("INSERT INTO ticket (%s) VALUES (%s)" 
    196208                       % (','.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]) 
    199211        tkt_id = db.get_last_id(cursor, 'ticket') 
    200212 
    201213        # Insert custom fields 
     
    291303            db.commit() 
    292304        old_values = self._old 
    293305        self._old = {} 
    294         self.time_changed = when 
     306        self.values['changetime'] = when 
    295307 
    296308        for listener in TicketSystem(self.env).change_listeners: 
    297309            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  
    149149 
    150150    def format_props(self): 
    151151        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')] 
    153154        width = [0, 0, 0, 0] 
    154155        i = 0 
    155156        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  
    3131from trac.resource import Resource 
    3232from trac.ticket.api import TicketSystem 
    3333from trac.util import Ranges 
    34 from trac.util.datefmt import to_timestamp, utc 
     34from trac.util.datefmt import format_datetime, parse_date, to_timestamp, utc 
    3535from trac.util.presentation import Paginator 
    3636from trac.util.text import shorten_line 
    3737from trac.util.translation import _, tag_ 
     
    5656        self.env = env 
    5757        self.id = report # if not None, it's the corresponding saved query 
    5858        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 
    6061        self.desc = desc 
    6162        self.group = group 
    6263        self.groupdesc = groupdesc 
     
    100101        if verbose and 'description' not in rows: # 0.10 compatibility 
    101102            rows.append('description') 
    102103        self.fields = TicketSystem(self.env).get_ticket_fields() 
     104        self.time_fields = [f['name'] for f in self.fields 
     105                            if f['type'] == 'time'] 
    103106        field_names = [f['name'] for f in self.fields] 
    104107        self.cols = [c for c in cols or [] if c in field_names or  
    105                      c in ('id', 'time', 'changetime')] 
     108                     c == 'id'] 
    106109        self.rows = [c for c in rows if c in field_names] 
    107110        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' 
    117112 
    118113        if self.group not in field_names: 
    119114            self.group = None 
     
    124119        kw_strs = ['order', 'group', 'page', 'max'] 
    125120        kw_arys = ['rows'] 
    126121        kw_bools = ['desc', 'groupdesc', 'verbose'] 
     122        synonyms = TicketSystem(env).get_field_synonyms() 
    127123        constraints = {} 
    128124        cols = [] 
    129125        for filter_ in filters: 
     
    131127            if len(filter_) != 2: 
    132128                raise QuerySyntaxError(_('Query filter requires field and '  
    133129                                         'constraints separated by a "="')) 
    134             field,values = filter_ 
     130            field, values = filter_ 
    135131            if not field: 
    136132                raise QuerySyntaxError(_('Query filter requires field name')) 
    137133            # from last char of `field`, get the mode of comparison 
     
    157153                elif field in kw_bools: 
    158154                    kw[field] = True 
    159155                elif field == 'col': 
    160                     cols.extend(processed_values) 
     156                    cols.extend(synonyms.get(value, value) 
     157                                for value in processed_values) 
    161158                else: 
    162                     constraints[field] = processed_values 
     159                    constraints.setdefault(synonyms.get(field, field),  
     160                                           []).extend(processed_values) 
    163161            except UnicodeError: 
    164162                pass # field must be a str, see `get_href()` 
    165163        report = constraints.pop('report', None) 
     
    185183            if col in cols: 
    186184                cols.remove(col) 
    187185                cols.append(col) 
    188         # TODO: fix after adding time/changetime to the api.py 
    189         cols += ['time', 'changetime'] 
    190186 
    191187        # Semi-intelligently remove columns that are restricted to a single 
    192188        # value by a query constraint. 
     
    195191            constraint = self.constraints[col] 
    196192            if len(constraint) == 1 and constraint[0] \ 
    197193                    and not constraint[0][0] in ('!', '~', '^', '$'): 
    198                 if col in cols: 
     194                if col in cols and col not in self.time_fields: 
    199195                    cols.remove(col) 
    200196            if col == 'status' and not 'closed' in constraint \ 
    201197                    and 'resolution' in cols: 
     
    299295                    result['href'] = req.href.ticket(val) 
    300296                elif val is None: 
    301297                    val = '--' 
    302                 elif name in ('changetime', 'time'): 
     298                elif name in self.time_fields: 
    303299                    val = datetime.fromtimestamp(int(val or 0), utc) 
    304300                elif field and field['type'] == 'checkbox': 
    305301                    try: 
     
    425421 
    426422        def get_constraint_sql(name, value, mode, neg): 
    427423            if name not in custom_fields: 
    428                 name = 't.' + name 
     424                col = 't.' + name 
    429425            else: 
    430                 name = name + '.value' 
     426                col = name + '.value' 
    431427            value = value[len(mode) + neg:] 
    432428 
     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                 
    433453            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 
    436457            if not value: 
    437458                return None 
    438             db = self.env.get_db_cnx() 
    439459            value = db.like_escape(value) 
    440460            if mode == '~': 
    441461                value = '%' + value + '%' 
     
    443463                value = value + '%' 
    444464            elif mode == '$': 
    445465                value = '%' + value 
    446             return ("COALESCE(%s,'') %s%s" % (name, neg and 'NOT ' or '', 
     466            return ("COALESCE(%s,'') %s%s" % (col, neg and 'NOT ' or '', 
    447467                                              db.like()), 
    448                     value) 
     468                    (value, )) 
    449469 
     470        db = self.env.get_db_cnx() 
    450471        clauses = [] 
    451472        args = [] 
    452473        for k, v in self.constraints.items(): 
     
    480501                    clauses.append('%s(%s)' % (neg and 'NOT ' or '', 
    481502                                               ' OR '.join(id_clauses))) 
    482503            # 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: 
    484505                if k not in custom_fields: 
    485506                    col = 't.' + k 
    486507                else: 
     
    501522                else: 
    502523                    clauses.append("(" + " OR ".join( 
    503524                        [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]) 
    505527            elif len(v) == 1: 
    506528                constraint_sql = get_constraint_sql(k, v[0], mode, neg) 
    507529                if constraint_sql: 
    508530                    clauses.append(constraint_sql[0]) 
    509                     args.append(constraint_sql[1]) 
     531                    args.extend(constraint_sql[1]) 
    510532 
    511533        clauses = filter(None, clauses) 
    512534        if clauses: 
     
    530552            # FIXME: This is a somewhat ugly hack.  Can we also have the 
    531553            #        column type for this?  If it's an integer, we do first 
    532554            #        one, if text, we do 'else' 
    533             if name in ('id', 'time', 'changetime'): 
     555            if name == 'id' or name in self.time_fields: 
    534556                sql.append("COALESCE(%s,0)=0%s," % (col, desc)) 
    535557            else: 
    536558                sql.append("COALESCE(%s,'')=''%s," % (col, desc)) 
     
    576598        labels = dict([(f['name'], f['label']) for f in self.fields]) 
    577599        wikify = set(f['name'] for f in self.fields  
    578600                     if f['type'] == 'text' and f.get('format') == 'wiki') 
    579  
    580         # TODO: remove after adding time/changetime to the api.py 
    581         labels['changetime'] = _('Modified') 
    582         labels['time'] = _('Created') 
    583601 
    584602        headers = [{ 
    585603            'name': col, 'label': labels.get(col, _('Ticket')), 
     
    829847 
    830848    def _get_constraints(self, req): 
    831849        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] 
    834853        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]) 
    835858 
    836859        # For clients without JavaScript, we remove constraints here if 
    837860        # requested 
     
    853876                mode = req.args.get(field + '_mode') 
    854877                if mode: 
    855878                    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)] 
    856884                if field in remove_constraints: 
    857885                    idx = remove_constraints[field] 
    858886                    if idx >= 0: 
     
    861889                            continue 
    862890                    else: 
    863891                        continue 
    864                 constraints[field] = vals 
     892                constraints.setdefault(synonyms.get(field, field),  
     893                                       []).extend(vals) 
    865894 
    866895        return constraints 
    867896 
     
    957986                    if col in ('cc', 'reporter'): 
    958987                        value = Chrome(self.env).format_emails(context(ticket), 
    959988                                                               value) 
     989                    elif col in query.time_fields: 
     990                        value = format_datetime(value, tzinfo=req.tz) 
    960991                    values.append(unicode(value).encode('utf-8')) 
    961992                writer.writerow(values) 
    962993        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  
    4343            <py:for each="field_name in field_names" py:with="field = fields[field_name]"> 
    4444              <py:for each="constraint_name, constraint in constraints.items()"> 
    4545                <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')"> 
    4747                  <py:for each="constraint_idx, constraint_value in enumerate(constraint['values'])"> 
    4848                    <tr class="${field_name}" py:if="multiline or constraint_idx == 0"> 
    4949                      <py:choose test="constraint_idx"> 
    5050                        <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"> 
    5353                            <select name="${field_name}_mode"> 
    5454                              <option py:for="mode in modes[field.type]" value="$mode.value" 
    5555                                selected="${mode.value == constraint.mode and 'selected' or None}">$mode.name 
     
    5858                          </td> 
    5959                        </py:when> 
    6060                        <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> 
    6262                        </py:otherwise> 
    6363                      </py:choose> 
    6464 
    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}" 
    6666                          py:choose=""> 
    6767 
    6868                        <py:when test="field.type == 'select'"> 
     
    8080                              value="$option" 
    8181                              checked="${any([(value == option) == (constraint.mode == '') 
    8282                                              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> 
    8484                          </py:for> 
    8585                        </py:when> 
    8686 
    8787                        <py:when test="field.type == 'checkbox'"> 
    8888                          <input type="radio" id="${field_name}_on" name="$field_name" value="1" 
    8989                                 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> 
    9191                          <input type="radio" id="${field_name}_off" name="$field_name" value="0" 
    9292                                 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> 
    9494                        </py:when> 
    9595 
    9696                        <py:when test="field.type in ('text', 'textarea')"> 
    9797                          <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" /> 
    98107                        </py:when> 
    99108 
    100109                      </td> 
  • trac/ticket/templates/ticket.html

    diff --git a/trac/ticket/templates/ticket.html b/trac/ticket/templates/ticket.html
    a b  
    125125        <div id="ticket" py:if="ticket.exists or preview_mode" 
    126126          class="${preview_mode and 'ticketdraft' or None}"> 
    127127          <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> 
    130130            <p py:if="not ticket.exists"><i>(ticket not yet created)</i></p> 
    131131          </div> 
    132132          <!-- 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  
    605605                # note project title / URL are not validated yet 
    606606 
    607607        # 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'] 
    609610        # check banner content (field exists, msg value matches ticket value) 
    610611        for p in [prop for prop in ticket.values.keys() if prop not in xlist]: 
    611612            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  
    22from trac.mimeview import Context 
    33from trac.test import Mock, EnvironmentStub, MockPerm 
    44from trac.ticket.query import Query, QueryModule 
     5from trac.util.datefmt import utc 
    56from trac.web.href import Href 
    67from trac.wiki.formatter import LinkFormatter 
    78from trac.db.sqlite_backend import sqlite_version 
     
    3536 
    3637    def setUp(self): 
    3738        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) 
    3940         
    4041 
    4142    def test_all_ordered_by_id(self): 
     
    336337        self.assertEqual([], args) 
    337338        tickets = query.execute(self.req) 
    338339 
     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 
     345FROM ticket AS t 
     346  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 
     347WHERE (CAST(t.time AS int)>=%s AND CAST(t.time AS int)<%s) 
     348ORDER 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 
     357FROM ticket AS t 
     358  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 
     359WHERE NOT (CAST(t.time AS int)>=%s AND CAST(t.time AS int)<%s) 
     360ORDER 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 
     369FROM ticket AS t 
     370  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 
     371WHERE CAST(t.time AS int)>=%s 
     372ORDER 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 
     381FROM ticket AS t 
     382  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 
     383WHERE CAST(t.time AS int)<%s 
     384ORDER 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 
     393FROM ticket AS t 
     394  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority) 
     395WHERE (CAST(t.changetime AS int)>=%s AND CAST(t.changetime AS int)<%s) 
     396ORDER BY COALESCE(t.id,0)=0,t.id""") 
     397        self.assertEqual([1217548800, 1220227200], args) 
     398        tickets = query.execute(self.req) 
     399 
    339400    def test_csv_escape(self): 
    340401        query = Mock(get_columns=lambda: ['col1'], 
    341402                     execute=lambda r,c: [{'id': 1, 
    342                                            'col1': 'value, needs escaped'}]) 
     403                                           'col1': 'value, needs escaped'}], 
     404                     time_fields=['time', 'changetime']) 
    343405        content, mimetype = QueryModule(self.env).export_csv( 
    344406                                Mock(href=self.env.href, perm=MockPerm()), 
    345407                                query) 
  • trac/ticket/web_ui.py

    diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
    a b  
    3939from trac.timeline.api import ITimelineEventProvider 
    4040from trac.util import get_reporter_id, partition 
    4141from trac.util.compat import any 
    42 from trac.util.datefmt import to_timestamp, utc 
     42from trac.util.datefmt import format_datetime, to_timestamp, utc 
    4343from trac.util.text import CRLF, shorten_line, obfuscate_email_address 
    4444from trac.util.presentation import separated 
    4545from trac.util.translation import _, tag_, N_, gettext 
     
    526526                         'reassign_owner': req.authname, 
    527527                         'resolve_resolution': None, 
    528528                         # Store a timestamp for detecting "mid air collisions" 
    529                          'timestamp': str(ticket.time_changed)}) 
     529                         'timestamp': str(ticket['changetime'])}) 
    530530 
    531531        self._insert_ticket_data(req, ticket, data, 
    532532                                 get_reporter_id(req, 'author'), field_changes) 
     
    661661        history = [c for c in history if any([f in text_fields 
    662662                                              for f in c['fields']])] 
    663663        history.append({'version': 0, 'comment': "''Initial version''", 
    664                         'date': ticket.time_created, 
     664                        'date': ticket['time'], 
    665665                        'author': ticket['reporter'] # not 100% accurate... 
    666666                        }) 
    667667        data.update({'title': _('Ticket History'), 
     
    833833    def export_csv(self, req, ticket, sep=',', mimetype='text/plain'): 
    834834        # FIXME: consider dumping history of changes here as well 
    835835        #        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')] 
    836838        content = StringIO() 
    837839        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]) 
    839841 
    840842        context = Context.from_request(req, ticket.resource) 
    841843        cols = [unicode(ticket.id)] 
    842         for f in ticket.fields: 
     844        for f in fields: 
    843845            name = f['name'] 
    844846            value = ticket.values.get(name, '') 
    845847            if name in ('cc', 'reporter'): 
    846848                value = Chrome(self.env).format_emails(context, value, ' ') 
     849            elif name in ticket.time_fields: 
     850                value = format_datetime(value, tzinfo=req.tz) 
    847851            cols.append(value.encode('utf-8')) 
    848852        writer.writerow(cols) 
    849853        return (content.getvalue(), '%s;charset=utf-8' % mimetype) 
     
    909913 
    910914        # Mid air collision? 
    911915        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']): 
    913917                add_warning(req, _("Sorry, can not save your changes. " 
    914918                              "This ticket has been modified by someone else " 
    915919                              "since you started")) 
     
    11111115 
    11121116            # per field settings 
    11131117            if name in ('summary', 'reporter', 'description', 'status', 
    1114                         'resolution'): 
     1118                        'resolution', 'time', 'changetime'): 
    11151119                field['skip'] = True 
    11161120            elif name == 'owner': 
    11171121                field['skip'] = True 
  • trac/util/datefmt.py

    diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py
    a b  
    172172 
    173173def parse_date(text, tzinfo=None): 
    174174    tzinfo = tzinfo or localtz 
    175     if text == 'now': # TODO: today, yesterday, etc. 
    176         return datetime.now(utc) 
    177     tm = None 
     175    dt = None 
    178176    text = text.strip() 
    179177    # normalize ISO time 
    180178    match = _ISO_8601_RE.match(text) 
     
    198196            tm = time.strptime('%s ' * 6 % (years, months, days, 
    199197                                            hours, minutes, seconds), 
    200198                               '%Y %m %d %H %M %S ') 
     199            dt = datetime(*(tm[0:6] + (0, tzinfo))) 
    201200        except ValueError: 
    202201            pass 
    203     else: 
     202    if dt is None: 
    204203        for format in ['%x %X', '%x, %X', '%X %x', '%X, %x', '%x', '%c', 
    205204                       '%b %d, %Y']: 
    206205            try: 
    207206                tm = time.strptime(text, format) 
     207                dt = datetime(*(tm[0:6] + (0, tzinfo))) 
    208208                break 
    209209            except ValueError: 
    210210                continue 
    211     if tm == None: 
     211    if dt is None: 
     212        dt = _parse_relative_time(text, tzinfo) 
     213    if dt is None: 
    212214        hint = get_date_format_hint()         
    213215        raise TracError('"%s" is an invalid date, or the date format ' 
    214216                        'is not known. Try "%s" instead.' % (text, hint), 
    215217                        'Invalid Date') 
    216     dt = datetime(*(tm[0:6] + (0, tzinfo))) 
    217218    # Make sure we can convert it to a timestamp and back - fromtimestamp() 
    218219    # may raise ValueError if larger than platform C localtime() or gmtime() 
    219220    try: 
     
    223224                        'Try a date closer to present time.' % (text,), 
    224225                        'Invalid Date') 
    225226    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 
     262def _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 
    226289 
    227290 
    228291# -- timezone utilities