Edgewall Software

Ticket #2288: 2288-time-queries-r7499.2.patch

File 2288-time-queries-r7499.2.patch, 34.4 KB (added by rblank, 3 years ago)

Updated patch fixing unit tests and a few issues

  • 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 } 
    4241#filters td.actions { text-align: right; white-space: nowrap } 
    4342 
    4443#columns div label {  
  • trac/htdocs/js/query.js

    diff --git a/trac/htdocs/js/query.js b/trac/htdocs/js/query.js
    a b  
    225225        td.className = "filter"; 
    226226        if (property.type == "select") { 
    227227          var element = createSelect(propertyName, property.options, true); 
     228          td.appendChild(element); 
    228229        } else if (property.type == "text") { 
    229230          var element = document.createElement("input"); 
    230231          element.type = "text"; 
    231232          element.name = propertyName; 
    232233          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); 
    233249        } 
    234         td.appendChild(element); 
    235250        element.focus(); 
    236251        tr.appendChild(td); 
    237252      } 
  • 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)', 
     
    278284 
    279285        fields.sort(lambda x, y: cmp(x['order'], y['order'])) 
    280286        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'} 
    281292 
    282293    # IPermissionRequestor methods 
    283294 
  • 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: 
     
    102108 
    103109        self.id = tkt_id 
    104110        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 '' 
    108116 
    109117        # Fetch custom fields if available 
    110118        custom_fields = [f['name'] for f in self.fields if f.get('custom')] 
     
    164172        # Add a timestamp 
    165173        if when is None: 
    166174            when = datetime.now(utc) 
    167         self.time_created = self.time_changed = when 
     175        self.values['time'] = self.values['changetime'] = when 
    168176 
    169177        cursor = db.cursor() 
    170178 
     
    178186                # No such component exists 
    179187                pass 
    180188 
     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         
    181195        # Insert ticket record 
    182         created = to_timestamp(self.time_created) 
    183         changed = to_timestamp(self.time_changed) 
    184196        std_fields = [] 
    185197        custom_fields = [] 
    186198        for f in self.fields: 
     
    190202                    custom_fields.append(fname) 
    191203                else: 
    192204                    std_fields.append(fname) 
    193         cursor.execute("INSERT INTO ticket (%s,time,changetime) VALUES (%s)" 
     205        cursor.execute("INSERT INTO ticket (%s) VALUES (%s)" 
    194206                       % (','.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]) 
    197209        tkt_id = db.get_last_id(cursor, 'ticket') 
    198210 
    199211        # Insert custom fields 
     
    289301            db.commit() 
    290302        old_values = self._old 
    291303        self._old = {} 
    292         self.time_changed = when 
     304        self.values['changetime'] = when 
    293305 
    294306        for listener in TicketSystem(self.env).change_listeners: 
    295307            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 
     
    156152                elif field in kw_bools: 
    157153                    kw[field] = True 
    158154                elif field == 'col': 
    159                     cols.extend(processed_values) 
     155                    cols.extend(synonyms.get(value, value) 
     156                                for value in processed_values) 
    160157                else: 
    161                     constraints[field] = processed_values 
     158                    constraints.setdefault(synonyms.get(field, field),  
     159                                           []).extend(processed_values) 
    162160            except UnicodeError: 
    163161                pass # field must be a str, see `get_href()` 
    164162        report = constraints.pop('report', None) 
     
    184182            if col in cols: 
    185183                cols.remove(col) 
    186184                cols.append(col) 
    187         # TODO: fix after adding time/changetime to the api.py 
    188         cols += ['time', 'changetime'] 
    189185 
    190186        # Semi-intelligently remove columns that are restricted to a single 
    191187        # value by a query constraint. 
     
    194190            constraint = self.constraints[col] 
    195191            if len(constraint) == 1 and constraint[0] \ 
    196192                    and not constraint[0][0] in ('!', '~', '^', '$'): 
    197                 if col in cols: 
     193                if col in cols and col not in self.time_fields: 
    198194                    cols.remove(col) 
    199195            if col == 'status' and not 'closed' in constraint \ 
    200196                    and 'resolution' in cols: 
     
    298294                    result['href'] = req.href.ticket(val) 
    299295                elif val is None: 
    300296                    val = '--' 
    301                 elif name in ('changetime', 'time'): 
     297                elif name in self.time_fields: 
    302298                    val = datetime.fromtimestamp(int(val or 0), utc) 
    303299                elif field and field['type'] == 'checkbox': 
    304300                    try: 
     
    424420 
    425421        def get_constraint_sql(name, value, mode, neg): 
    426422            if name not in custom_fields: 
    427                 name = 't.' + name 
     423                col = 't.' + name 
    428424            else: 
    429                 name = name + '.value' 
     425                col = name + '.value' 
    430426            value = value[len(mode) + neg:] 
    431427 
     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                 
    432452            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 
    435456            if not value: 
    436457                return None 
    437             db = self.env.get_db_cnx() 
    438458            value = db.like_escape(value) 
    439459            if mode == '~': 
    440460                value = '%' + value + '%' 
     
    442462                value = value + '%' 
    443463            elif mode == '$': 
    444464                value = '%' + value 
    445             return ("COALESCE(%s,'') %s%s" % (name, neg and 'NOT ' or '', 
     465            return ("COALESCE(%s,'') %s%s" % (col, neg and 'NOT ' or '', 
    446466                                              db.like()), 
    447                     value) 
     467                    (value, )) 
    448468 
     469        db = self.env.get_db_cnx() 
    449470        clauses = [] 
    450471        args = [] 
    451472        for k, v in self.constraints.items(): 
     
    479500                    clauses.append('%s(%s)' % (neg and 'NOT ' or '', 
    480501                                               ' OR '.join(id_clauses))) 
    481502            # 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: 
    483504                if k not in custom_fields: 
    484505                    col = 't.' + k 
    485506                else: 
     
    500521                else: 
    501522                    clauses.append("(" + " OR ".join( 
    502523                        [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]) 
    504526            elif len(v) == 1: 
    505527                constraint_sql = get_constraint_sql(k, v[0], mode, neg) 
    506528                if constraint_sql: 
    507529                    clauses.append(constraint_sql[0]) 
    508                     args.append(constraint_sql[1]) 
     530                    args.extend(constraint_sql[1]) 
    509531 
    510532        clauses = filter(None, clauses) 
    511533        if clauses or cached_ids: 
     
    531553            # FIXME: This is a somewhat ugly hack.  Can we also have the 
    532554            #        column type for this?  If it's an integer, we do first 
    533555            #        one, if text, we do 'else' 
    534             if name in ('id', 'time', 'changetime'): 
     556            if name == 'id' or name in self.time_fields: 
    535557                sql.append("COALESCE(%s,0)=0%s," % (col, desc)) 
    536558            else: 
    537559                sql.append("COALESCE(%s,'')=''%s," % (col, desc)) 
     
    576598        cols = self.get_columns() 
    577599        labels = dict([(f['name'], f['label']) for f in self.fields]) 
    578600 
    579         # TODO: remove after adding time/changetime to the api.py 
    580         labels['changetime'] = _('Modified') 
    581         labels['time'] = _('Created') 
    582  
    583601        headers = [{ 
    584602            'name': col, 'label': labels.get(col, _('Ticket')), 
    585603            'href': self.get_href(context.href, order=col, 
     
    607625        modes['select'] = [ 
    608626            {'name': _("is"), 'value': ""}, 
    609627            {'name': _("is not"), 'value': "!"} 
     628        ] 
     629        modes['time'] = [ 
     630            {'name': _("is between"), 'value': ""}, 
     631            {'name': _("is not between"), 'value': "!"}, 
    610632        ] 
    611633 
    612634        groups = {} 
     
    807829 
    808830    def _get_constraints(self, req): 
    809831        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] 
    812835        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]) 
    813840 
    814841        # For clients without JavaScript, we remove constraints here if 
    815842        # requested 
     
    831858                mode = req.args.get(field + '_mode') 
    832859                if mode: 
    833860                    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)] 
    834866                if field in remove_constraints: 
    835867                    idx = remove_constraints[field] 
    836868                    if idx >= 0: 
     
    839871                            continue 
    840872                    else: 
    841873                        continue 
    842                 constraints[field] = vals 
     874                constraints.setdefault(synonyms.get(field, field),  
     875                                       []).extend(vals) 
    843876 
    844877        return constraints 
    845878 
     
    935968                    if col in ('cc', 'reporter'): 
    936969                        value = Chrome(self.env).format_emails(context(ticket), 
    937970                                                               value) 
     971                    elif col in query.time_fields: 
     972                        value = format_datetime(value, tzinfo=req.tz) 
    938973                    values.append(unicode(value).encode('utf-8')) 
    939974                writer.writerow(values) 
    940975        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  
    4242            <py:for each="field_name, field in fields.items()"> 
    4343              <py:for each="constraint_name, constraint in constraints.items()"> 
    4444                <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')"> 
    4646                  <py:for each="constraint_idx, constraint_value in enumerate(constraint['values'])"> 
    4747                    <tr class="${field_name}" py:if="multiline or constraint_idx == 0"> 
    4848                      <py:choose test="constraint_idx"> 
     
    9494 
    9595                        <py:when test="'text'"> 
    9696                          <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" /> 
    97105                        </py:when> 
    98106 
    99107                      </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 
     
    520520                         'reassign_owner': req.authname, 
    521521                         'resolve_resolution': None, 
    522522                         # Store a timestamp for detecting "mid air collisions" 
    523                          'timestamp': str(ticket.time_changed)}) 
     523                         'timestamp': str(ticket['changetime'])}) 
    524524 
    525525        self._insert_ticket_data(req, ticket, data, 
    526526                                 get_reporter_id(req, 'author'), field_changes) 
     
    653653        history = [c for c in history if any([f in text_fields 
    654654                                              for f in c['fields']])] 
    655655        history.append({'version': 0, 'comment': "''Initial version''", 
    656                         'date': ticket.time_created, 
     656                        'date': ticket['time'], 
    657657                        'author': ticket['reporter'] # not 100% accurate... 
    658658                        }) 
    659659        data.update({'title': _('Ticket History'), 
     
    825825    def export_csv(self, req, ticket, sep=',', mimetype='text/plain'): 
    826826        # FIXME: consider dumping history of changes here as well 
    827827        #        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')] 
    828830        content = StringIO() 
    829831        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]) 
    831833 
    832834        context = Context.from_request(req, ticket.resource) 
    833835        cols = [unicode(ticket.id)] 
    834         for f in ticket.fields: 
     836        for f in fields: 
    835837            name = f['name'] 
    836838            value = ticket.values.get(name, '') 
    837839            if name in ('cc', 'reporter'): 
    838840                value = Chrome(self.env).format_emails(context, value, ' ') 
     841            elif name in ticket.time_fields: 
     842                value = format_datetime(value, tzinfo=req.tz) 
    839843            cols.append(value.encode('utf-8')) 
    840844        writer.writerow(cols) 
    841845        return (content.getvalue(), '%s;charset=utf-8' % mimetype) 
     
    901905 
    902906        # Mid air collision? 
    903907        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']): 
    905909                add_warning(req, _("Sorry, can not save your changes. " 
    906910                              "This ticket has been modified by someone else " 
    907911                              "since you started")) 
     
    10851089 
    10861090            # per field settings 
    10871091            if name in ('summary', 'reporter', 'description', 'status', 
    1088                         'resolution'): 
     1092                        'resolution', 'time', 'changetime'): 
    10891093                field['skip'] = True 
    10901094            elif name == 'owner': 
    10911095                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