Edgewall Software

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

File 2288-time-queries-r7578.patch, 41.2 KB (added by rblank, 7 weeks 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}"