Edgewall Software

Ticket #1942: customtimefields.patch

File customtimefields.patch, 31.9 KB (added by shoffmann, 6 months ago)

rebase cumulative changes from branch "ticket-1942" at r10880 (beware: one chunk)

  • trac/ticket/api.py

    # HG changeset patch
    # Parent 3e22fab66b4f204eab1db2ae28b8cfeb968b041e
    Add code from the stagnant private branch in rblank's Hg repo for
    TicketCustomTimeFields support, see
     https://bitbucket.org/rblank/trac/compare/ticket-1942..jquery-ui-datetimepicker
    .
    
    diff --git a/trac/ticket/api.py b/trac/ticket/api.py
    a b  
    3030from trac.wiki import IWikiSyntaxProvider, WikiParser 
    3131 
    3232 
     33class TicketFieldList(list): 
     34    """Improved ticket field list, allowing access by name.""" 
     35    __slots__ = ['_map'] 
     36 
     37    def __init__(self, *args): 
     38        super(TicketFieldList, self).__init__(*args) 
     39        self._map = dict((value['name'], value) for value in self) 
     40 
     41    def append(self, value): 
     42        super(TicketFieldList, self).append(value) 
     43        self._map[value['name']] = value 
     44 
     45    def by_name(self, name, default=None): 
     46        return self._map.get(name, default) 
     47 
     48    def __copy__(self): 
     49        return TicketFieldList(self) 
     50 
     51    def __deepcopy__(self, memo): 
     52        return TicketFieldList(copy.deepcopy(value, memo) for value in self) 
     53 
     54 
    3355class ITicketActionController(Interface): 
    3456    """Extension point interface for components willing to participate 
    3557    in the ticket workflow. 
     
    280302        """Return the list of fields available for tickets.""" 
    281303        from trac.ticket import model 
    282304 
    283         fields = [] 
     305        fields = TicketFieldList() 
    284306 
    285307        # Basic text fields 
    286308        fields.append({'name': 'summary', 'type': 'text', 
     
    329351        fields.append({'name': 'cc', 'type': 'text', 'label': N_('Cc')}) 
    330352 
    331353        # Date/time fields 
    332         fields.append({'name': 'time', 'type': 'time', 
     354        fields.append({'name': 'time', 'type': 'time', 'format': 'age', 
    333355                       'label': N_('Created')}) 
    334         fields.append({'name': 'changetime', 'type': 'time', 
     356        fields.append({'name': 'changetime', 'type': 'time', 'format': 'age', 
    335357                       'label': N_('Modified')}) 
    336358 
    337         for field in self.get_custom_fields(): 
     359        for field in self.custom_fields: 
    338360            if field['name'] in [f['name'] for f in fields]: 
    339361                self.log.warning('Duplicate field name "%s" (ignoring)', 
    340362                                 field['name']) 
     
    347369                self.log.warning('Invalid name for custom field: "%s" ' 
    348370                                 '(ignoring)', field['name']) 
    349371                continue 
    350             field['custom'] = True 
    351372            fields.append(field) 
    352373 
    353374        return fields 
     
    362383    @cached 
    363384    def custom_fields(self, db): 
    364385        """Return the list of custom ticket fields available for tickets.""" 
    365         fields = [] 
     386        fields = TicketFieldList() 
    366387        config = self.ticket_custom_section 
    367388        for name in [option for option, value in config.options() 
    368389                     if '.' not in option]: 
    369390            field = { 
    370                 'name': name, 
     391                'name': name, 'custom': True, 
    371392                'type': config.get(name), 
    372393                'order': config.getint(name + '.order', 0), 
    373394                'label': config.get(name + '.label') or name.capitalize(), 
     
    384405                field['format'] = config.get(name + '.format', 'plain') 
    385406                field['width'] = config.getint(name + '.cols') 
    386407                field['height'] = config.getint(name + '.rows') 
     408            elif field['type'] == 'time': 
     409                field['format'] = config.get(name + '.format', 'datetime') 
    387410            fields.append(field) 
    388411 
    389412        fields.sort(lambda x, y: cmp((x['order'], x['name']), 
  • trac/ticket/model.py

    diff --git a/trac/ticket/model.py b/trac/ticket/model.py
    a b  
    2828from trac.ticket.api import TicketSystem 
    2929from trac.util import embedded_numbers, partition 
    3030from trac.util.text import empty 
    31 from trac.util.datefmt import from_utimestamp, to_utimestamp, utc, utcmax 
     31from trac.util.datefmt import from_utimestamp, parse_date, to_utimestamp, \ 
     32                              utc, utcmax 
    3233from trac.util.translation import _ 
    3334 
    3435__all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity', 
     
    4445    return ', '.join(cclist) 
    4546 
    4647 
     48def _str_to_datetime(value): 
     49    if value is None: 
     50        return None 
     51    try: 
     52        return from_utimestamp(long(value)) 
     53    except ValueError: 
     54        pass 
     55    try: 
     56        return parse_date(value.strip(), utc, 'datetime') 
     57    except Exception: 
     58        return None 
     59 
     60 
     61def _datetime_to_str(dt): 
     62    if dt: 
     63        return str(to_utimestamp(dt)) 
     64    return '' 
     65 
     66 
    4767class Ticket(object): 
    4868 
    4969    # Fields that must not be modified directly by the user 
     
    133153                SELECT name, value FROM ticket_custom WHERE ticket=%s 
    134154                """, (tkt_id,)): 
    135155            if name in self.custom_fields: 
    136                 if value is None: 
     156                if name in self.time_fields: 
     157                    self.values[name] = _str_to_datetime(value) 
     158                elif value is None: 
    137159                    self.values[name] = empty 
    138160                else: 
    139161                    self.values[name] = value 
     
    150172            self._old[name] = self.values.get(name) 
    151173        elif self._old[name] == value: # Change of field reverted 
    152174            del self._old[name] 
    153         if value: 
     175        if value and name not in self.time_fields: 
    154176            if isinstance(value, list): 
    155177                raise TracError(_("Multi-values fields not supported yet")) 
    156             field = [field for field in self.fields if field['name'] == name] 
    157             if field and field[0].get('type') != 'textarea': 
     178            if self.fields.by_name(name, {}).get('type') != 'textarea': 
    158179                value = value.strip() 
    159180        self.values[name] = value 
    160181 
     
    165186            value = self.values[name] 
    166187            if value is not empty: 
    167188                return value 
    168             field = [field for field in self.fields if field['name'] == name] 
    169             if field: 
    170                 return field[0].get('value', '') 
     189            return self.fields.by_name(name, {}).get('value', '') 
    171190        except KeyError: 
    172191            pass 
    173192 
     
    175194        """Populate the ticket with 'suitable' values from a dictionary""" 
    176195        field_names = [f['name'] for f in self.fields] 
    177196        for name in [name for name in values.keys() if name in field_names]: 
    178             self[name] = values.get(name, '') 
     197            self[name] = values[name] 
    179198 
    180199        # We have to do an extra trick to catch unchecked checkboxes 
    181200        for name in [name for name in values.keys() if name[9:] in field_names 
     
    214233            self['owner'] = default_to_owner 
    215234 
    216235        # Perform type conversions 
    217         values = dict(self.values) 
    218         for field in self.time_fields: 
    219             if field in values: 
    220                 values[field] = to_utimestamp(values[field]) 
     236        values = self._to_db_types(self.values) 
    221237 
    222238        # Insert ticket record 
    223239        std_fields = [] 
     
    241257            if custom_fields: 
    242258                db.executemany( 
    243259                    """INSERT INTO ticket_custom (ticket, name, value) 
    244                       VALUES (%s, %s, %s) 
    245                       """, 
    246                     [(tkt_id, c, self[c]) for c in custom_fields]) 
     260                       VALUES (%s, %s, %s) 
     261                       """, [(tkt_id, c, values.get(c, '')) 
     262                            for c in custom_fields]) 
    247263 
    248264        self.id = tkt_id 
    249265        self.resource = self.resource(id=tkt_id) 
     
    297313                    # we just leave the owner as is. 
    298314                    pass 
    299315 
     316        # Perform type conversions 
     317        values = self._to_db_types(self.values) 
     318        old_values = self._to_db_types(self._old) 
     319 
    300320        with self.env.db_transaction as db: 
    301321            db("UPDATE ticket SET changetime=%s WHERE id=%s", 
    302322               (when_ts, self.id)) 
     
    330350                                     """, (self.id, name)): 
    331351                        db("""UPDATE ticket_custom SET value=%s 
    332352                              WHERE ticket=%s AND name=%s 
    333                               """, (self[name], self.id, name)) 
     353                              """, (values.get(name, ''), self.id, name)) 
    334354                        break 
    335355                    else: 
    336356                        db("""INSERT INTO ticket_custom (ticket,name,value) 
    337357                              VALUES(%s,%s,%s) 
    338                               """, (self.id, name, self[name])) 
     358                              """, (self.id, name, values.get(name, ''))) 
    339359                else: 
    340360                    db("UPDATE ticket SET %s=%%s WHERE id=%%s"  
    341                        % name, (self[name], self.id)) 
     361                       % name, (values.get(name, ''), self.id)) 
    342362                db("""INSERT INTO ticket_change 
    343363                        (ticket,time,author,field,oldvalue,newvalue) 
    344364                      VALUES (%s, %s, %s, %s, %s, %s) 
    345                       """, (self.id, when_ts, author, name, self._old[name], 
    346                             self[name])) 
     365                      """, (self.id, when_ts, author, name, old_values[name], 
     366                            values.get(name, ''))) 
    347367 
    348368            # always save comment, even if empty  
    349369            # (numbering support for timeline) 
     
    360380            listener.ticket_changed(self, comment, author, old_values) 
    361381        return int(cnum.rsplit('.', 1)[-1]) 
    362382 
     383    def _to_db_types(self, values): 
     384        values = values.copy() 
     385        for field, value in values.iteritems(): 
     386            if field in self.time_fields: 
     387                if field in self.custom_fields: 
     388                    values[field] = _datetime_to_str(value) 
     389                else: 
     390                    values[field] = to_utimestamp(value) 
     391        return values 
     392 
    363393    def get_changelog(self, when=None, db=None): 
    364394        """Return the changelog as a list of tuples of the form 
    365395        (time, author, field, oldvalue, newvalue, permanent). 
     
    403433                ORDER BY time,permanent,author 
    404434                """ 
    405435            args = (self.id, sid, sid) 
    406         return [(from_utimestamp(t), author, field, oldvalue or '', 
    407                  newvalue or '', permanent) 
    408                 for t, author, field, oldvalue, newvalue, permanent in 
    409                 self.env.db_query(sql, args)] 
     436        log = [] 
     437        for t, author, field, oldvalue, newvalue, permanent \ 
     438                in self.env.db_query(sql, args): 
     439            if field in self.time_fields: 
     440                oldvalue = _str_to_datetime(oldvalue) 
     441                newvalue = _str_to_datetime(newvalue) 
     442            log.append((from_utimestamp(t), author, field, 
     443                        oldvalue or '', newvalue or '', permanent)) 
     444        return log 
    410445 
    411446    def delete(self, db=None): 
    412447        """Delete the ticket. 
  • trac/ticket/notification.py

    diff --git a/trac/ticket/notification.py b/trac/ticket/notification.py
    a b  
    2626from trac.config import * 
    2727from trac.notification import NotifyEmail 
    2828from trac.ticket.api import TicketSystem 
    29 from trac.util.datefmt import to_utimestamp 
     29from trac.util.datefmt import format_date, format_datetime, timezone, \ 
     30                              to_utimestamp 
    3031from trac.util.text import obfuscate_email_address, text_width, wrap 
    3132from trac.util.translation import deactivate, reactivate 
    3233 
     
    162163                        if field in ['owner', 'reporter']: 
    163164                            old = obfuscate_email_address(old) 
    164165                            new = obfuscate_email_address(new) 
     166                        elif field in ticket.time_fields: 
     167                            format = ticket.fields.by_name(field).get('format') 
     168                            old = self.format_time_field(old, format) 
     169                            new = self.format_time_field(new, format) 
    165170                        newv = new 
    166171                        length = 7 + len(field) 
    167172                        spacer_old, spacer_new = ' ', ' ' 
     
    220225            if not fname in tkt.values: 
    221226                continue 
    222227            fval = tkt[fname] or '' 
     228            if fname in tkt.time_fields: 
     229                format = tkt.fields.by_name(fname).get('format') 
     230                fval = self.format_time_field(fval, format) 
    223231            if fval.find('\n') != -1: 
    224232                continue 
    225233            if fname in ['owner', 'reporter']: 
     
    253261            if not tkt.values.has_key(fname): 
    254262                continue 
    255263            fval = tkt[fname] or '' 
     264            if fname in tkt.time_fields: 
     265                format = tkt.fields.by_name(fname).get('format') 
     266                fval = self.format_time_field(fval, format) 
    256267            if fname in ['owner', 'reporter']: 
    257268                fval = obfuscate_email_address(fval) 
    258269            if f['type'] == 'textarea' or '\n' in unicode(fval): 
     
    320331         
    321332        return template.generate(**data).render('text', encoding=None).strip() 
    322333 
     334    def format_time_field(self, value, format): 
     335        try: 
     336            tzinfo = timezone(self.config.get('trac', 'default_timezone')) 
     337        except KeyError: 
     338            tzinfo = None 
     339        if format == 'date': 
     340            return format_date(value, tzinfo=tzinfo) if value else '' 
     341        else: 
     342            return format_datetime(value, tzinfo=tzinfo) if value else '' 
     343 
    323344    def get_recipients(self, tktid): 
    324345        notify_reporter = self.config.getbool('notification', 
    325346                                              'always_notify_reporter') 
  • trac/ticket/query.py

    diff --git a/trac/ticket/query.py b/trac/ticket/query.py
    a b  
    3434from trac.ticket.api import TicketSystem 
    3535from trac.ticket.model import Milestone, group_milestones 
    3636from trac.util import Ranges, as_bool 
    37 from trac.util.datefmt import format_datetime, from_utimestamp, parse_date, \ 
    38                               to_timestamp, to_utimestamp, utc, user_time 
     37from trac.util.datefmt import format_date, format_datetime, from_utimestamp, \ 
     38                              parse_date, pretty_timedelta, to_timestamp, \ 
     39                              to_utimestamp, utc, user_time 
    3940from trac.util.presentation import Paginator 
    4041from trac.util.text import empty, shorten_line, quote_query_string 
    4142from trac.util.translation import _, tag_, cleandoc_ 
     
    316317            # self.env.log.debug("SQL: " + sql % tuple([repr(a) for a in args])) 
    317318            cursor.execute(sql, args) 
    318319            columns = get_column_names(cursor) 
    319             fields = [] 
    320             for column in columns: 
    321                 fields += [f for f in self.fields if f['name'] == column] or \ 
    322                           [None] 
     320            fields = [self.fields.by_name(column, None) for column in columns] 
    323321            results = [] 
    324322 
    325323            column_indices = range(len(columns)) 
     
    334332                        if href is not None: 
    335333                            result['href'] = href.ticket(val) 
    336334                    elif name in self.time_fields: 
    337                         val = from_utimestamp(val) 
     335                        val = from_utimestamp(long(val)) if val else '' 
    338336                    elif field and field['type'] == 'checkbox': 
    339337                        try: 
    340338                            val = bool(int(val)) 
     
    711709 
    712710        cols = self.get_columns() 
    713711        labels = TicketSystem(self.env).get_ticket_field_labels() 
    714         wikify = set(f['name'] for f in self.fields  
    715                      if f['type'] == 'text' and f.get('format') == 'wiki') 
    716712 
    717713        headers = [{ 
    718714            'name': col, 'label': labels.get(col, _('Ticket')), 
    719             'wikify': col in wikify, 
     715            'field': self.fields.by_name(col, {}), 
    720716            'href': self.get_href(context.href, order=col, 
    721717                                  desc=(col == self.order and not self.desc)) 
    722718        } for col in cols] 
     
    10711067                add_warning(req, error) 
    10721068 
    10731069        context = web_context(req, 'query') 
    1074         owner_field = [f for f in query.fields if f['name'] == 'owner'] 
     1070        owner_field = query.fields.by_name('owner', None) 
    10751071        if owner_field: 
    10761072            TicketSystem(self.env).eventually_restrict_owner(owner_field[0]) 
    10771073        data = query.template_data(context, tickets, orig_list, orig_time, req) 
     
    11411137                        value = Chrome(self.env).format_emails( 
    11421138                                    context.child(ticket), value) 
    11431139                    elif col in query.time_fields: 
    1144                         value = format_datetime(value, '%Y-%m-%d %H:%M:%S', 
    1145                                                 tzinfo=req.tz) 
     1140                        format = query.fields.by_name(col).get('format') 
     1141                        if format == 'age': 
     1142                            value = pretty_timedelta(value) if value else '' 
     1143                        elif format == 'date': 
     1144                            value = format_date(value, '%Y-%m-%d', 
     1145                                                tzinfo=req.tz) if value else '' 
     1146                        else: 
     1147                            value = format_datetime(value, '%Y-%m-%d %H:%M:%S', 
     1148                                                    tzinfo=req.tz) \ 
     1149                                    if value else '' 
    11461150                    values.append(unicode(value).encode('utf-8')) 
    11471151                writer.writerow(values) 
    11481152        return (content.getvalue(), '%s;charset=utf-8' % mimetype) 
  • trac/ticket/templates/query_results.html

    diff --git a/trac/ticket/templates/query_results.html b/trac/ticket/templates/query_results.html
    a b  
    7575                        class="${classes(closed=result.status == 'closed')}">#$result.id</a></td> 
    7676                    <td py:otherwise="" class="$name" py:choose=""> 
    7777                      <a py:when="name == 'summary'" href="$result.href" title="View ticket">$value</a> 
     78                      <py:when test="isinstance(value, datetime)"> 
     79                        <py:choose test="header.field.format"> 
     80                          <py:when test="'age'">${dateinfo(value)}</py:when> 
     81                          <py:when test="'date'">${format_date(value, tzinfo=req.tz)}</py:when> 
     82                          <py:otherwise>${format_datetime(value, tzinfo=req.tz)}</py:otherwise> 
     83                        </py:choose> 
     84                      </py:when> 
     85<!--! 
    7886                      <py:when test="isinstance(value, datetime)">${pretty_dateinfo(value, dateonly=True)}</py:when> 
     87--> 
    7988                      <py:when test="name == 'reporter'">${authorinfo(value)}</py:when> 
    8089                      <py:when test="name == 'cc'">${format_emails(ticket_context, value)}</py:when> 
    8190                      <py:when test="name == 'owner' and value">${authorinfo(value)}</py:when> 
    8291                      <py:when test="name == 'milestone'"><a py:if="value" title="View milestone" href="${href.milestone(value)}">${value}</a></py:when> 
    83                       <py:when test="header.wikify">${wiki_to_oneliner(ticket_context, value)}</py:when> 
     92                      <py:when test="header.field.type == 'text' 
     93                                     and header.field.format == 'wiki'">${wiki_to_oneliner(ticket_context, value)}</py:when> 
    8494                      <py:otherwise>$value</py:otherwise> 
    8595                    </td> 
    8696                  </py:with> 
  • trac/ticket/templates/ticket.html

    diff --git a/trac/ticket/templates/ticket.html b/trac/ticket/templates/ticket.html
    a b  
    265265                                 checked="${value == option or None}" /> 
    266266                          ${option} 
    267267                        </label> 
     268                        <input py:when="'time'" type="text" id="field-${field.name}" title="${field.format_hint}" 
     269                               name="field_${field.name}" value="${field.edit}" /> 
    268270                        <py:otherwise><!--! Text input fields --> 
    269271                          <py:choose> 
    270272                            <span py:when="field.cc_entry"><!--! Special case for Cc: field --> 
    271273                              <em>${field.cc_entry}</em> 
    272274                              <input type="checkbox" id="field-cc" name="cc_update" 
    273                                 title="This checkbox allows you to add or remove yourself from the CC list." 
    274                                 checked="${field.cc_update}" /> 
     275                                     title="This checkbox allows you to add or remove yourself from the CC list." 
     276                                     checked="${field.cc_update}" /> 
    275277                            </span> 
    276278                            <!--! Cc: when TICKET_EDIT_CC is allowed --> 
    277279                            <span py:when="field.name == 'cc'"> 
    278                               <input  type="text" id="field-${field.name}" 
    279                                 title="Space or comma delimited email addresses and usernames are accepted." 
    280                                 name="field_${field.name}" value="${value}" /> 
     280                              <input type="text" id="field-${field.name}" 
     281                                     title="Space or comma delimited email addresses and usernames are accepted." 
     282                                     name="field_${field.name}" value="${value}" /> 
    281283                            </span> 
    282284                            <!--! All the other text input fields --> 
    283285                            <input py:otherwise="" type="text" id="field-${field.name}" 
    284                               name="field_${field.name}" value="${value}" /> 
     286                                   name="field_${field.name}" value="${value}" /> 
    285287                          </py:choose> 
    286288                        </py:otherwise> 
    287289                      </py:choose> 
  • trac/ticket/tests/api.py

    diff --git a/trac/ticket/tests/api.py b/trac/ticket/tests/api.py
    a b  
    3131        self.env.config.set('ticket-custom', 'test.format', 'wiki') 
    3232        fields = TicketSystem(self.env).get_custom_fields() 
    3333        self.assertEqual({'name': 'test', 'type': 'text', 'label': 'Test', 
    34                           'value': 'Foo bar', 'order': 0, 'format': 'wiki'}, 
     34                          'value': 'Foo bar', 'order': 0, 'format': 'wiki', 
     35                          'custom': True}, 
    3536                         fields[0]) 
    3637 
    3738    def test_custom_field_select(self): 
     
    4243        fields = TicketSystem(self.env).get_custom_fields() 
    4344        self.assertEqual({'name': 'test', 'type': 'select', 'label': 'Test', 
    4445                          'value': '1', 'options': ['option1', 'option2'], 
    45                           'order': 0}, 
     46                          'order': 0, 'custom': True}, 
    4647                         fields[0]) 
    4748 
    4849    def test_custom_field_optional_select(self): 
     
    5354        fields = TicketSystem(self.env).get_custom_fields() 
    5455        self.assertEqual({'name': 'test', 'type': 'select', 'label': 'Test', 
    5556                          'value': '1', 'options': ['option1', 'option2'], 
    56                           'order': 0, 'optional': True}, 
     57                          'order': 0, 'optional': True, 'custom': True}, 
    5758                         fields[0]) 
    5859 
    5960    def test_custom_field_textarea(self): 
     
    6667        fields = TicketSystem(self.env).get_custom_fields() 
    6768        self.assertEqual({'name': 'test', 'type': 'textarea', 'label': 'Test', 
    6869                          'value': 'Foo bar', 'width': 60, 'height': 4, 
    69                           'order': 0, 'format': 'wiki'}, 
     70                          'order': 0, 'format': 'wiki', 'custom': True}, 
    7071                         fields[0]) 
    7172 
    7273    def test_custom_field_order(self): 
  • trac/ticket/web_ui.py

    diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
    a b  
    3737from trac.ticket.notification import TicketNotifyEmail 
    3838from trac.timeline.api import ITimelineEventProvider 
    3939from trac.util import as_bool, as_int, get_reporter_id 
    40 from trac.util.datefmt import format_datetime, from_utimestamp, \ 
    41                               to_utimestamp, utc 
     40from trac.util.datefmt import format_datetime, format_date, format_datetime, from_utimestamp, \ 
     41                              get_date_format_hint, get_datetime_format_hint, \ 
     42                              pretty_timedelta, parse_date, to_utimestamp, utc 
    4243from trac.util.text import exception_to_unicode, obfuscate_email_address, \ 
    4344                           shorten_line, to_unicode 
    4445from trac.util.presentation import separated 
     
    702703        for each in Ticket.protected_fields: 
    703704            fields.pop(each, None) 
    704705            fields.pop('checkbox_' + each, None)    # See Ticket.populate() 
     706        for field, value in fields.iteritems(): 
     707            if field in ticket.time_fields: 
     708                fields[field] = parse_date(value, req.tz, 'datetime') \ 
     709                                if value else None 
    705710        ticket.populate(fields) 
    706711        # special case for updating the Cc: field 
    707712        if 'cc_update' in req.args: 
     
    10561061            if name in ('cc', 'reporter'): 
    10571062                value = Chrome(self.env).format_emails(context, value, ' ') 
    10581063            elif name in ticket.time_fields: 
    1059                 value = format_datetime(value, '%Y-%m-%d %H:%M:%S', 
    1060                                         tzinfo=req.tz) 
     1064                format = ticket.fields.by_name(name).get('format') 
     1065                value = self._render_time_field(req, value, format) 
    10611066            cols.append(value.encode('utf-8')) 
    10621067        writer.writerow(cols) 
    10631068        return (content.getvalue(), '%s;charset=utf-8' % mimetype) 
     
    11961201            # Shouldn't happen in "normal" circumstances, hence not a warning 
    11971202            raise InvalidTicket(_("Invalid comment threading identifier")) 
    11981203 
     1204        # FIXME: Validate time field content 
     1205 
    11991206        # Custom validation rules 
    12001207        for manipulator in self.ticket_manipulators: 
    12011208            for field, message in manipulator.validate_ticket(req, ticket): 
     
    13621369            type_ = field['type'] 
    13631370  
    13641371            # enable a link to custom query for all choice fields 
    1365             if type_ not in ['text', 'textarea']: 
     1372            if type_ not in ['text', 'textarea', 'time']: 
    13661373                field['rendered'] = self._query_link(req, name, ticket[name]) 
    13671374 
    13681375            # per field settings 
     
    14371444                    field['rendered'] = \ 
    14381445                        format_to_html(self.env, context, ticket[name], 
    14391446                                escape_newlines=self.must_preserve_newlines) 
     1447            elif type_ == 'time': 
     1448                value = ticket[name] 
     1449                format = field.get('format', 'datetime') 
     1450                field['rendered'] = self._render_time_field(req, value, format, 
     1451                                                            relative=True) 
     1452                field['edit'] = self._render_time_field(req, value, format) 
     1453                if format == 'date': 
     1454                    field['format_hint'] = get_date_format_hint() 
     1455                else: 
     1456                    field['format_hint'] = get_datetime_format_hint() 
    14401457             
    14411458            # ensure sane defaults 
    14421459            field.setdefault('optional', False) 
     
    14491466            fields.remove(owner_field) 
    14501467            fields.append(owner_field) 
    14511468        return fields 
    1452          
     1469 
    14531470    def _insert_ticket_data(self, req, ticket, data, author_id, field_changes): 
    14541471        """Insert ticket data into the template `data`""" 
    14551472        replyto = req.args.get('replyto') 
     
    16021619                                                  resource_new) 
    16031620            if rendered: 
    16041621                changes['rendered'] = rendered 
     1622            elif ticket.fields.by_name(field, {}).get('type') == 'time': 
     1623                format = ticket.fields.by_name(field).get('format') 
     1624                changes['old'] = self._render_time_field(req, old, format) 
     1625                changes['new'] = self._render_time_field(req, new, format) 
    16051626 
    16061627    def _render_property_diff(self, req, ticket, field, old, new,  
    16071628                              resource_new=None): 
    16081629        rendered = None 
    16091630        # per type special rendering of diffs 
    1610         type_ = None 
    1611         for f in ticket.fields: 
    1612             if f['name'] == field: 
    1613                 type_ = f['type'] 
    1614                 break 
     1631        type_ = ticket.fields.by_name(field, {}).get('type') 
    16151632        if type_ == 'checkbox': 
    16161633            rendered = _("set") if new == '1' else _("unset") 
    16171634        elif type_ == 'textarea': 
     
    16621679                                old=tag.em(old), new=tag.em(new)) 
    16631680        return rendered 
    16641681 
     1682    def _render_time_field(self, req, value, format, relative=False): 
     1683        format = format or 'datetime' 
     1684        if format == 'age' and relative: 
     1685            return pretty_timedelta(value) if value else '' 
     1686        elif format == 'date': 
     1687            return format_date(value, '%Y-%m-%d', tzinfo=req.tz) \ 
     1688                   if value else '' 
     1689        else: 
     1690            return format_datetime(value, '%Y-%m-%d %H:%M:%S', tzinfo=req.tz) \ 
     1691                   if value else '' 
     1692 
    16651693    def grouped_changelog_entries(self, ticket, db=None, when=None): 
    16661694        """Iterate on changelog entries, consolidating related changes 
    16671695        in a `dict` object. 
  • trac/util/datefmt.py

    diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py
    a b  
    513513    t = tzinfo.localize(datetime(*(values[k] for k in 'yMdhms'))) 
    514514    return tzinfo.normalize(t) 
    515515 
    516 _REL_TIME_RE = re.compile( 
    517     r'(\d+\.?\d*)\s*' 
    518     r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*' 
    519     r'(?:ago)?$') 
     516_REL_FUTURE_RE = re.compile( 
     517    r'(?:in|\+)\s*(\d+\.?\d*)\s*' 
     518    r'(second|minute|hour|day|week|month|year|[hdwmy])s?$') 
     519_REL_PAST_RE = re.compile( 
     520    r'(?:-\s*)?(\d+\.?\d*)\s*' 
     521    r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*(?:ago)?$') 
    520522_time_intervals = dict( 
    521523    second=lambda v: timedelta(seconds=v), 
    522524    minute=lambda v: timedelta(minutes=v), 
     
    531533    m=lambda v: timedelta(days=30 * v), 
    532534    y=lambda v: timedelta(days=365 * v), 
    533535) 
    534 _TIME_START_RE = re.compile(r'(this|last)\s*' 
     536_TIME_START_RE = re.compile(r'(this|last|next)\s*' 
    535537                            r'(second|minute|hour|day|week|month|year)$') 
    536538_time_starts = dict( 
    537539    second=lambda now: now.replace(microsecond=0), 
     
    543545    month=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0, 
    544546                                  day=1), 
    545547    year=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0, 
    546                                   day=1, month=1), 
     548                                 day=1, month=1), 
    547549) 
    548550 
    549551def _parse_relative_time(text, tzinfo): 
     
    555557    if text == 'yesterday': 
    556558        return now.replace(microsecond=0, second=0, minute=0, hour=0) \ 
    557559               - timedelta(days=1) 
    558     match = _REL_TIME_RE.match(text) 
     560    if text == 'tomorrow': 
     561        return now.replace(microsecond=0, second=0, minute=0, hour=0) \ 
     562               + timedelta(days=1) 
     563    match = _REL_FUTURE_RE.match(text) 
    559564    if match: 
    560         (value, interval) = match.groups() 
     565        value, interval = match.groups() 
     566        return now + _time_intervals[interval](float(value)) 
     567    match = _REL_PAST_RE.match(text) 
     568    if match: 
     569        value, interval = match.groups() 
    561570        return now - _time_intervals[interval](float(value)) 
    562571    match = _TIME_START_RE.match(text) 
    563572    if match: 
    564         (which, start) = match.groups() 
     573        which, start = match.groups() 
    565574        dt = _time_starts[start](now) 
    566575        if which == 'last': 
    567576            if start == 'month': 
     
    571580                    dt = dt.replace(year=dt.year - 1, month=12) 
    572581            else: 
    573582                dt -= _time_intervals[start](1) 
     583        elif which == 'next': 
     584            if start == 'month': 
     585                if dt.month < 12: 
     586                    dt = dt.replace(month=dt.month + 1) 
     587                else: 
     588                    dt = dt.replace(year=dt.year + 1, month=1) 
     589            else: 
     590                dt += _time_intervals[start](1) 
    574591        return dt 
    575     return None 
    576592 
    577593# -- formatting/parsing helper functions 
    578594