Anonymous avatar Anonymous committed a55269a

Initial import equal to published patches in edgewall's T#1942.

Comments (0)

Files changed (6)

complete-usertime.patch

+# HG changeset patch
+# Parent d05cb61b36696d6306a88b08abc13e49ca78b6e0
+Add `user_time` to TicketQueryMacro too.
+
+diff --git a/trac/ticket/templates/query_results.html b/trac/ticket/templates/query_results.html
+--- a/trac/ticket/templates/query_results.html
++++ b/trac/ticket/templates/query_results.html
+@@ -77,9 +77,9 @@
+                       <a py:when="name == 'summary'" href="$result.href" title="View ticket">$value</a>
+                       <py:when test="isinstance(value, datetime)">
+                         <py:choose test="header.field.format">
+-                          <py:when test="'age'">${dateinfo(value)}</py:when>
+-                          <py:when test="'date'">${format_date(value, tzinfo=req.tz)}</py:when>
+-                          <py:otherwise>${format_datetime(value, tzinfo=req.tz)}</py:otherwise>
++                          <py:when test="'age'">${pretty_dateinfo(value)}</py:when>
++                          <py:when test="'date'">${user_time(req, format_date, value)}</py:when>
++                          <py:otherwise>${user_time(req, format_datetime, value)}</py:otherwise>
+                         </py:choose>
+                       </py:when>
+ <!--!
+diff --git a/trac/web/chrome.py b/trac/web/chrome.py
+--- a/trac/web/chrome.py
++++ b/trac/web/chrome.py
+@@ -919,6 +919,7 @@
+             'fromtimestamp': partial(datetime.datetime.fromtimestamp,
+                                      tz=req and req.tz),
+             'from_utimestamp': from_utimestamp,
++            'user_time': user_time,
+ 
+             # Wiki-formatting functions
+             'wiki_to': partial(format_to, self.env),

ct-restrict-owner-fix.patch

+# HG changeset patch
+# Parent e44f51de5a9ef32c96ed814c182eec8b0e54b251
+Restore list type lost in previous changeset(s) and add `user_time` for
+custom (i18n aware) date/time formatting and parsing available after [10571].
+
+diff --git a/trac/ticket/query.py b/trac/ticket/query.py
+--- a/trac/ticket/query.py
++++ b/trac/ticket/query.py
+@@ -1067,7 +1067,7 @@
+                 add_warning(req, error)
+ 
+         context = web_context(req, 'query')
+-        owner_field = query.fields.by_name('owner', None)
++        owner_field = [query.fields.by_name('owner', None)]
+         if owner_field:
+             TicketSystem(self.env).eventually_restrict_owner(owner_field[0])
+         data = query.template_data(context, tickets, orig_list, orig_time, req)
+diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
+--- a/trac/ticket/web_ui.py
++++ b/trac/ticket/web_ui.py
+@@ -37,9 +37,10 @@
+ from trac.ticket.notification import TicketNotifyEmail
+ from trac.timeline.api import ITimelineEventProvider
+ from trac.util import as_bool, as_int, get_reporter_id
+-from trac.util.datefmt import format_datetime, format_date, format_datetime, from_utimestamp, \
+-                              get_date_format_hint, get_datetime_format_hint, \
+-                              pretty_timedelta, parse_date, to_utimestamp, utc
++from trac.util.datefmt import format_datetime, format_date, format_datetime, \
++                              from_utimestamp, get_date_format_hint, \
++                              get_datetime_format_hint, pretty_timedelta, \
++                              parse_date, to_utimestamp, user_time, utc
+ from trac.util.text import exception_to_unicode, obfuscate_email_address, \
+                            shorten_line, to_unicode
+ from trac.util.presentation import separated
+@@ -705,7 +706,7 @@
+             fields.pop('checkbox_' + each, None)    # See Ticket.populate()
+         for field, value in fields.iteritems():
+             if field in ticket.time_fields:
+-                fields[field] = parse_date(value, req.tz, 'datetime') \
++                fields[field] = user_time(req, parse_date, value) \
+                                 if value else None
+         ticket.populate(fields)
+         # special case for updating the Cc: field
+@@ -1684,11 +1685,9 @@
+         if format == 'age' and relative:
+             return pretty_timedelta(value) if value else ''
+         elif format == 'date':
+-            return format_date(value, '%Y-%m-%d', tzinfo=req.tz) \
+-                   if value else ''
++            return user_time(req, format_date, value) if value else ''
+         else:
+-            return format_datetime(value, '%Y-%m-%d %H:%M:%S', tzinfo=req.tz) \
+-                   if value else ''
++            return user_time(req, format_datetime, value) if value else ''
+ 
+     def grouped_changelog_entries(self, ticket, db=None, when=None):
+         """Iterate on changelog entries, consolidating related changes
+# HG changeset patch
+# Parent 5ffda3c2392c73b914c902e4ea0303ee5d462a19
+Add some tests for custom time fields implementation.
+
+diff --git a/trac/ticket/tests/api.py b/trac/ticket/tests/api.py
+--- a/trac/ticket/tests/api.py
++++ b/trac/ticket/tests/api.py
+@@ -70,6 +70,16 @@
+                           'order': 0, 'format': 'wiki', 'custom': True},
+                          fields[0])
+ 
++    def test_custom_field_time(self):
++        self.env.config.set('ticket-custom', 'test', 'time')
++        self.env.config.set('ticket-custom', 'test.label', 'Test')
++        self.env.config.set('ticket-custom', 'test.value', '')
++        fields = TicketSystem(self.env).get_custom_fields()
++        self.assertEqual({'name': 'test', 'type': 'time', 'label': 'Test',
++                          'value': '', 'order': 0, 'format': 'datetime',
++                          'custom': True},
++                         fields[0])
++
+     def test_custom_field_order(self):
+         self.env.config.set('ticket-custom', 'test1', 'text')
+         self.env.config.set('ticket-custom', 'test1.order', '2')
+diff --git a/trac/ticket/tests/model.py b/trac/ticket/tests/model.py
+--- a/trac/ticket/tests/model.py
++++ b/trac/ticket/tests/model.py
+@@ -298,6 +298,26 @@
+         self.assertEqual('on', ticket['cbon'])
+         self.assertEqual('0', ticket['cboff'])
+ 
++    def test_custom_time(self):
++        # Add a custom field of type 'time'
++        self.env.config.set('ticket-custom', 'due', 'time')
++        ticket = Ticket(self.env)
++        self.assertFalse('due' in ticket.std_fields)
++        self.assertTrue('due' in ticket.time_fields)
++        ticket['reporter'] = 'john'
++        ticket['summary'] = 'Task1'
++        tktid = ticket.insert()
++        ticket = Ticket(self.env, tktid)
++        # Empty string is default value, but not a time stamp
++        self.assertEqual(None, ticket['due'])
++        ts = datetime(2011, 11, 11, 0, 0, 0, 0, utc)
++        ticket['due'] = ts
++        ticket.save_changes('joe')
++        self.assertEqual(ts, ticket['due'])
++        ticket['due'] = ''
++        ticket.save_changes('joe')
++        self.assertEqual('', ticket['due'])
++
+     def test_changelog(self):
+         tkt_id = self._insert_ticket('Test', reporter='joe', component='foo',
+                                      milestone='bar')

customtimefields.patch

+# 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/trac/ticket/api.py
++++ b/trac/ticket/api.py
+@@ -30,6 +30,28 @@
+ from trac.wiki import IWikiSyntaxProvider, WikiParser
+ 
+ 
++class TicketFieldList(list):
++    """Improved ticket field list, allowing access by name."""
++    __slots__ = ['_map']
++
++    def __init__(self, *args):
++        super(TicketFieldList, self).__init__(*args)
++        self._map = dict((value['name'], value) for value in self)
++
++    def append(self, value):
++        super(TicketFieldList, self).append(value)
++        self._map[value['name']] = value
++
++    def by_name(self, name, default=None):
++        return self._map.get(name, default)
++
++    def __copy__(self):
++        return TicketFieldList(self)
++
++    def __deepcopy__(self, memo):
++        return TicketFieldList(copy.deepcopy(value, memo) for value in self)
++
++
+ class ITicketActionController(Interface):
+     """Extension point interface for components willing to participate
+     in the ticket workflow.
+@@ -280,7 +302,7 @@
+         """Return the list of fields available for tickets."""
+         from trac.ticket import model
+ 
+-        fields = []
++        fields = TicketFieldList()
+ 
+         # Basic text fields
+         fields.append({'name': 'summary', 'type': 'text',
+@@ -329,12 +351,12 @@
+         fields.append({'name': 'cc', 'type': 'text', 'label': N_('Cc')})
+ 
+         # Date/time fields
+-        fields.append({'name': 'time', 'type': 'time',
++        fields.append({'name': 'time', 'type': 'time', 'format': 'age',
+                        'label': N_('Created')})
+-        fields.append({'name': 'changetime', 'type': 'time',
++        fields.append({'name': 'changetime', 'type': 'time', 'format': 'age',
+                        'label': N_('Modified')})
+ 
+-        for field in self.get_custom_fields():
++        for field in self.custom_fields:
+             if field['name'] in [f['name'] for f in fields]:
+                 self.log.warning('Duplicate field name "%s" (ignoring)',
+                                  field['name'])
+@@ -347,7 +369,6 @@
+                 self.log.warning('Invalid name for custom field: "%s" '
+                                  '(ignoring)', field['name'])
+                 continue
+-            field['custom'] = True
+             fields.append(field)
+ 
+         return fields
+@@ -362,12 +383,12 @@
+     @cached
+     def custom_fields(self, db):
+         """Return the list of custom ticket fields available for tickets."""
+-        fields = []
++        fields = TicketFieldList()
+         config = self.ticket_custom_section
+         for name in [option for option, value in config.options()
+                      if '.' not in option]:
+             field = {
+-                'name': name,
++                'name': name, 'custom': True,
+                 'type': config.get(name),
+                 'order': config.getint(name + '.order', 0),
+                 'label': config.get(name + '.label') or name.capitalize(),
+@@ -384,6 +405,8 @@
+                 field['format'] = config.get(name + '.format', 'plain')
+                 field['width'] = config.getint(name + '.cols')
+                 field['height'] = config.getint(name + '.rows')
++            elif field['type'] == 'time':
++                field['format'] = config.get(name + '.format', 'datetime')
+             fields.append(field)
+ 
+         fields.sort(lambda x, y: cmp((x['order'], x['name']),
+diff --git a/trac/ticket/model.py b/trac/ticket/model.py
+--- a/trac/ticket/model.py
++++ b/trac/ticket/model.py
+@@ -28,7 +28,8 @@
+ from trac.ticket.api import TicketSystem
+ from trac.util import embedded_numbers, partition
+ from trac.util.text import empty
+-from trac.util.datefmt import from_utimestamp, to_utimestamp, utc, utcmax
++from trac.util.datefmt import from_utimestamp, parse_date, to_utimestamp, \
++                              utc, utcmax
+ from trac.util.translation import _
+ 
+ __all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity',
+@@ -44,6 +45,25 @@
+     return ', '.join(cclist)
+ 
+ 
++def _str_to_datetime(value):
++    if value is None:
++        return None
++    try:
++        return from_utimestamp(long(value))
++    except ValueError:
++        pass
++    try:
++        return parse_date(value.strip(), utc, 'datetime')
++    except Exception:
++        return None
++
++
++def _datetime_to_str(dt):
++    if dt:
++        return str(to_utimestamp(dt))
++    return ''
++
++
+ class Ticket(object):
+ 
+     # Fields that must not be modified directly by the user
+@@ -133,7 +153,9 @@
+                 SELECT name, value FROM ticket_custom WHERE ticket=%s
+                 """, (tkt_id,)):
+             if name in self.custom_fields:
+-                if value is None:
++                if name in self.time_fields:
++                    self.values[name] = _str_to_datetime(value)
++                elif value is None:
+                     self.values[name] = empty
+                 else:
+                     self.values[name] = value
+@@ -150,11 +172,10 @@
+             self._old[name] = self.values.get(name)
+         elif self._old[name] == value: # Change of field reverted
+             del self._old[name]
+-        if value:
++        if value and name not in self.time_fields:
+             if isinstance(value, list):
+                 raise TracError(_("Multi-values fields not supported yet"))
+-            field = [field for field in self.fields if field['name'] == name]
+-            if field and field[0].get('type') != 'textarea':
++            if self.fields.by_name(name, {}).get('type') != 'textarea':
+                 value = value.strip()
+         self.values[name] = value
+ 
+@@ -165,9 +186,7 @@
+             value = self.values[name]
+             if value is not empty:
+                 return value
+-            field = [field for field in self.fields if field['name'] == name]
+-            if field:
+-                return field[0].get('value', '')
++            return self.fields.by_name(name, {}).get('value', '')
+         except KeyError:
+             pass
+ 
+@@ -175,7 +194,7 @@
+         """Populate the ticket with 'suitable' values from a dictionary"""
+         field_names = [f['name'] for f in self.fields]
+         for name in [name for name in values.keys() if name in field_names]:
+-            self[name] = values.get(name, '')
++            self[name] = values[name]
+ 
+         # We have to do an extra trick to catch unchecked checkboxes
+         for name in [name for name in values.keys() if name[9:] in field_names
+@@ -214,10 +233,7 @@
+             self['owner'] = default_to_owner
+ 
+         # Perform type conversions
+-        values = dict(self.values)
+-        for field in self.time_fields:
+-            if field in values:
+-                values[field] = to_utimestamp(values[field])
++        values = self._to_db_types(self.values)
+ 
+         # Insert ticket record
+         std_fields = []
+@@ -241,9 +257,9 @@
+             if custom_fields:
+                 db.executemany(
+                     """INSERT INTO ticket_custom (ticket, name, value)
+-                      VALUES (%s, %s, %s)
+-                      """,
+-                    [(tkt_id, c, self[c]) for c in custom_fields])
++                       VALUES (%s, %s, %s)
++                       """, [(tkt_id, c, values.get(c, ''))
++                             for c in custom_fields])
+ 
+         self.id = tkt_id
+         self.resource = self.resource(id=tkt_id)
+@@ -297,6 +313,10 @@
+                     # we just leave the owner as is.
+                     pass
+ 
++        # Perform type conversions
++        values = self._to_db_types(self.values)
++        old_values = self._to_db_types(self._old)
++
+         with self.env.db_transaction as db:
+             db("UPDATE ticket SET changetime=%s WHERE id=%s",
+                (when_ts, self.id))
+@@ -330,20 +350,20 @@
+                                      """, (self.id, name)):
+                         db("""UPDATE ticket_custom SET value=%s
+                               WHERE ticket=%s AND name=%s
+-                              """, (self[name], self.id, name))
++                              """, (values.get(name, ''), self.id, name))
+                         break
+                     else:
+                         db("""INSERT INTO ticket_custom (ticket,name,value)
+                               VALUES(%s,%s,%s)
+-                              """, (self.id, name, self[name]))
++                              """, (self.id, name, values.get(name, '')))
+                 else:
+                     db("UPDATE ticket SET %s=%%s WHERE id=%%s" 
+-                       % name, (self[name], self.id))
++                       % name, (values.get(name, ''), self.id))
+                 db("""INSERT INTO ticket_change
+                         (ticket,time,author,field,oldvalue,newvalue)
+                       VALUES (%s, %s, %s, %s, %s, %s)
+-                      """, (self.id, when_ts, author, name, self._old[name],
+-                            self[name]))
++                      """, (self.id, when_ts, author, name, old_values[name],
++                            values.get(name, '')))
+ 
+             # always save comment, even if empty 
+             # (numbering support for timeline)
+@@ -360,6 +380,16 @@
+             listener.ticket_changed(self, comment, author, old_values)
+         return int(cnum.rsplit('.', 1)[-1])
+ 
++    def _to_db_types(self, values):
++        values = values.copy()
++        for field, value in values.iteritems():
++            if field in self.time_fields:
++                if field in self.custom_fields:
++                    values[field] = _datetime_to_str(value)
++                else:
++                    values[field] = to_utimestamp(value)
++        return values
++
+     def get_changelog(self, when=None, db=None):
+         """Return the changelog as a list of tuples of the form
+         (time, author, field, oldvalue, newvalue, permanent).
+@@ -403,10 +433,15 @@
+                 ORDER BY time,permanent,author
+                 """
+             args = (self.id, sid, sid)
+-        return [(from_utimestamp(t), author, field, oldvalue or '',
+-                 newvalue or '', permanent)
+-                for t, author, field, oldvalue, newvalue, permanent in
+-                self.env.db_query(sql, args)]
++        log = []
++        for t, author, field, oldvalue, newvalue, permanent \
++                in self.env.db_query(sql, args):
++            if field in self.time_fields:
++                oldvalue = _str_to_datetime(oldvalue)
++                newvalue = _str_to_datetime(newvalue)
++            log.append((from_utimestamp(t), author, field,
++                        oldvalue or '', newvalue or '', permanent))
++        return log
+ 
+     def delete(self, db=None):
+         """Delete the ticket.
+diff --git a/trac/ticket/notification.py b/trac/ticket/notification.py
+--- a/trac/ticket/notification.py
++++ b/trac/ticket/notification.py
+@@ -26,7 +26,8 @@
+ from trac.config import *
+ from trac.notification import NotifyEmail
+ from trac.ticket.api import TicketSystem
+-from trac.util.datefmt import to_utimestamp
++from trac.util.datefmt import format_date, format_datetime, timezone, \
++                              to_utimestamp
+ from trac.util.text import obfuscate_email_address, text_width, wrap
+ from trac.util.translation import deactivate, reactivate
+ 
+@@ -162,6 +163,10 @@
+                         if field in ['owner', 'reporter']:
+                             old = obfuscate_email_address(old)
+                             new = obfuscate_email_address(new)
++                        elif field in ticket.time_fields:
++                            format = ticket.fields.by_name(field).get('format')
++                            old = self.format_time_field(old, format)
++                            new = self.format_time_field(new, format)
+                         newv = new
+                         length = 7 + len(field)
+                         spacer_old, spacer_new = ' ', ' '
+@@ -220,6 +225,9 @@
+             if not fname in tkt.values:
+                 continue
+             fval = tkt[fname] or ''
++            if fname in tkt.time_fields:
++                format = tkt.fields.by_name(fname).get('format')
++                fval = self.format_time_field(fval, format)
+             if fval.find('\n') != -1:
+                 continue
+             if fname in ['owner', 'reporter']:
+@@ -253,6 +261,9 @@
+             if not tkt.values.has_key(fname):
+                 continue
+             fval = tkt[fname] or ''
++            if fname in tkt.time_fields:
++                format = tkt.fields.by_name(fname).get('format')
++                fval = self.format_time_field(fval, format)
+             if fname in ['owner', 'reporter']:
+                 fval = obfuscate_email_address(fval)
+             if f['type'] == 'textarea' or '\n' in unicode(fval):
+@@ -320,6 +331,16 @@
+         
+         return template.generate(**data).render('text', encoding=None).strip()
+ 
++    def format_time_field(self, value, format):
++        try:
++            tzinfo = timezone(self.config.get('trac', 'default_timezone'))
++        except KeyError:
++            tzinfo = None
++        if format == 'date':
++            return format_date(value, tzinfo=tzinfo) if value else ''
++        else:
++            return format_datetime(value, tzinfo=tzinfo) if value else ''
++
+     def get_recipients(self, tktid):
+         notify_reporter = self.config.getbool('notification',
+                                               'always_notify_reporter')
+diff --git a/trac/ticket/query.py b/trac/ticket/query.py
+--- a/trac/ticket/query.py
++++ b/trac/ticket/query.py
+@@ -34,8 +34,9 @@
+ from trac.ticket.api import TicketSystem
+ from trac.ticket.model import Milestone, group_milestones
+ from trac.util import Ranges, as_bool
+-from trac.util.datefmt import format_datetime, from_utimestamp, parse_date, \
+-                              to_timestamp, to_utimestamp, utc, user_time
++from trac.util.datefmt import format_date, format_datetime, from_utimestamp, \
++                              parse_date, pretty_timedelta, to_timestamp, \
++                              to_utimestamp, utc, user_time
+ from trac.util.presentation import Paginator
+ from trac.util.text import empty, shorten_line, quote_query_string
+ from trac.util.translation import _, tag_, cleandoc_
+@@ -316,10 +317,7 @@
+             # self.env.log.debug("SQL: " + sql % tuple([repr(a) for a in args]))
+             cursor.execute(sql, args)
+             columns = get_column_names(cursor)
+-            fields = []
+-            for column in columns:
+-                fields += [f for f in self.fields if f['name'] == column] or \
+-                          [None]
++            fields = [self.fields.by_name(column, None) for column in columns]
+             results = []
+ 
+             column_indices = range(len(columns))
+@@ -334,7 +332,7 @@
+                         if href is not None:
+                             result['href'] = href.ticket(val)
+                     elif name in self.time_fields:
+-                        val = from_utimestamp(val)
++                        val = from_utimestamp(long(val)) if val else ''
+                     elif field and field['type'] == 'checkbox':
+                         try:
+                             val = bool(int(val))
+@@ -711,12 +709,10 @@
+ 
+         cols = self.get_columns()
+         labels = TicketSystem(self.env).get_ticket_field_labels()
+-        wikify = set(f['name'] for f in self.fields 
+-                     if f['type'] == 'text' and f.get('format') == 'wiki')
+ 
+         headers = [{
+             'name': col, 'label': labels.get(col, _('Ticket')),
+-            'wikify': col in wikify,
++            'field': self.fields.by_name(col, {}),
+             'href': self.get_href(context.href, order=col,
+                                   desc=(col == self.order and not self.desc))
+         } for col in cols]
+@@ -1071,7 +1067,7 @@
+                 add_warning(req, error)
+ 
+         context = web_context(req, 'query')
+-        owner_field = [f for f in query.fields if f['name'] == 'owner']
++        owner_field = query.fields.by_name('owner', None)
+         if owner_field:
+             TicketSystem(self.env).eventually_restrict_owner(owner_field[0])
+         data = query.template_data(context, tickets, orig_list, orig_time, req)
+@@ -1141,8 +1137,16 @@
+                         value = Chrome(self.env).format_emails(
+                                     context.child(ticket), value)
+                     elif col in query.time_fields:
+-                        value = format_datetime(value, '%Y-%m-%d %H:%M:%S',
+-                                                tzinfo=req.tz)
++                        format = query.fields.by_name(col).get('format')
++                        if format == 'age':
++                            value = pretty_timedelta(value) if value else ''
++                        elif format == 'date':
++                            value = format_date(value, '%Y-%m-%d',
++                                                tzinfo=req.tz) if value else ''
++                        else:
++                            value = format_datetime(value, '%Y-%m-%d %H:%M:%S',
++                                                    tzinfo=req.tz) \
++                                    if value else ''
+                     values.append(unicode(value).encode('utf-8'))
+                 writer.writerow(values)
+         return (content.getvalue(), '%s;charset=utf-8' % mimetype)
+diff --git a/trac/ticket/templates/query_results.html b/trac/ticket/templates/query_results.html
+--- a/trac/ticket/templates/query_results.html
++++ b/trac/ticket/templates/query_results.html
+@@ -75,12 +75,22 @@
+                         class="${classes(closed=result.status == 'closed')}">#$result.id</a></td>
+                     <td py:otherwise="" class="$name" py:choose="">
+                       <a py:when="name == 'summary'" href="$result.href" title="View ticket">$value</a>
++                      <py:when test="isinstance(value, datetime)">
++                        <py:choose test="header.field.format">
++                          <py:when test="'age'">${dateinfo(value)}</py:when>
++                          <py:when test="'date'">${format_date(value, tzinfo=req.tz)}</py:when>
++                          <py:otherwise>${format_datetime(value, tzinfo=req.tz)}</py:otherwise>
++                        </py:choose>
++                      </py:when>
++<!--!
+                       <py:when test="isinstance(value, datetime)">${pretty_dateinfo(value, dateonly=True)}</py:when>
++-->
+                       <py:when test="name == 'reporter'">${authorinfo(value)}</py:when>
+                       <py:when test="name == 'cc'">${format_emails(ticket_context, value)}</py:when>
+                       <py:when test="name == 'owner' and value">${authorinfo(value)}</py:when>
+                       <py:when test="name == 'milestone'"><a py:if="value" title="View milestone" href="${href.milestone(value)}">${value}</a></py:when>
+-                      <py:when test="header.wikify">${wiki_to_oneliner(ticket_context, value)}</py:when>
++                      <py:when test="header.field.type == 'text'
++                                     and header.field.format == 'wiki'">${wiki_to_oneliner(ticket_context, value)}</py:when>
+                       <py:otherwise>$value</py:otherwise>
+                     </td>
+                   </py:with>
+diff --git a/trac/ticket/templates/ticket.html b/trac/ticket/templates/ticket.html
+--- a/trac/ticket/templates/ticket.html
++++ b/trac/ticket/templates/ticket.html
+@@ -265,23 +265,25 @@
+                                  checked="${value == option or None}" />
+                           ${option}
+                         </label>
++                        <input py:when="'time'" type="text" id="field-${field.name}" title="${field.format_hint}"
++                               name="field_${field.name}" value="${field.edit}" />
+                         <py:otherwise><!--! Text input fields -->
+                           <py:choose>
+                             <span py:when="field.cc_entry"><!--! Special case for Cc: field -->
+                               <em>${field.cc_entry}</em>
+                               <input type="checkbox" id="field-cc" name="cc_update"
+-                                title="This checkbox allows you to add or remove yourself from the CC list."
+-                                checked="${field.cc_update}" />
++                                     title="This checkbox allows you to add or remove yourself from the CC list."
++                                     checked="${field.cc_update}" />
+                             </span>
+                             <!--! Cc: when TICKET_EDIT_CC is allowed -->
+                             <span py:when="field.name == 'cc'">
+-                              <input  type="text" id="field-${field.name}"
+-                                title="Space or comma delimited email addresses and usernames are accepted."
+-                                name="field_${field.name}" value="${value}" />
++                              <input type="text" id="field-${field.name}"
++                                     title="Space or comma delimited email addresses and usernames are accepted."
++                                     name="field_${field.name}" value="${value}" />
+                             </span>
+                             <!--! All the other text input fields -->
+                             <input py:otherwise="" type="text" id="field-${field.name}"
+-                              name="field_${field.name}" value="${value}" />
++                                   name="field_${field.name}" value="${value}" />
+                           </py:choose>
+                         </py:otherwise>
+                       </py:choose>
+diff --git a/trac/ticket/tests/api.py b/trac/ticket/tests/api.py
+--- a/trac/ticket/tests/api.py
++++ b/trac/ticket/tests/api.py
+@@ -31,7 +31,8 @@
+         self.env.config.set('ticket-custom', 'test.format', 'wiki')
+         fields = TicketSystem(self.env).get_custom_fields()
+         self.assertEqual({'name': 'test', 'type': 'text', 'label': 'Test',
+-                          'value': 'Foo bar', 'order': 0, 'format': 'wiki'},
++                          'value': 'Foo bar', 'order': 0, 'format': 'wiki',
++                          'custom': True},
+                          fields[0])
+ 
+     def test_custom_field_select(self):
+@@ -42,7 +43,7 @@
+         fields = TicketSystem(self.env).get_custom_fields()
+         self.assertEqual({'name': 'test', 'type': 'select', 'label': 'Test',
+                           'value': '1', 'options': ['option1', 'option2'],
+-                          'order': 0},
++                          'order': 0, 'custom': True},
+                          fields[0])
+ 
+     def test_custom_field_optional_select(self):
+@@ -53,7 +54,7 @@
+         fields = TicketSystem(self.env).get_custom_fields()
+         self.assertEqual({'name': 'test', 'type': 'select', 'label': 'Test',
+                           'value': '1', 'options': ['option1', 'option2'],
+-                          'order': 0, 'optional': True},
++                          'order': 0, 'optional': True, 'custom': True},
+                          fields[0])
+ 
+     def test_custom_field_textarea(self):
+@@ -66,7 +67,7 @@
+         fields = TicketSystem(self.env).get_custom_fields()
+         self.assertEqual({'name': 'test', 'type': 'textarea', 'label': 'Test',
+                           'value': 'Foo bar', 'width': 60, 'height': 4,
+-                          'order': 0, 'format': 'wiki'},
++                          'order': 0, 'format': 'wiki', 'custom': True},
+                          fields[0])
+ 
+     def test_custom_field_order(self):
+diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
+--- a/trac/ticket/web_ui.py
++++ b/trac/ticket/web_ui.py
+@@ -37,8 +37,9 @@
+ from trac.ticket.notification import TicketNotifyEmail
+ from trac.timeline.api import ITimelineEventProvider
+ from trac.util import as_bool, as_int, get_reporter_id
+-from trac.util.datefmt import format_datetime, from_utimestamp, \
+-                              to_utimestamp, utc
++from trac.util.datefmt import format_datetime, format_date, format_datetime, from_utimestamp, \
++                              get_date_format_hint, get_datetime_format_hint, \
++                              pretty_timedelta, parse_date, to_utimestamp, utc
+ from trac.util.text import exception_to_unicode, obfuscate_email_address, \
+                            shorten_line, to_unicode
+ from trac.util.presentation import separated
+@@ -702,6 +703,10 @@
+         for each in Ticket.protected_fields:
+             fields.pop(each, None)
+             fields.pop('checkbox_' + each, None)    # See Ticket.populate()
++        for field, value in fields.iteritems():
++            if field in ticket.time_fields:
++                fields[field] = parse_date(value, req.tz, 'datetime') \
++                                if value else None
+         ticket.populate(fields)
+         # special case for updating the Cc: field
+         if 'cc_update' in req.args:
+@@ -1056,8 +1061,8 @@
+             if name in ('cc', 'reporter'):
+                 value = Chrome(self.env).format_emails(context, value, ' ')
+             elif name in ticket.time_fields:
+-                value = format_datetime(value, '%Y-%m-%d %H:%M:%S',
+-                                        tzinfo=req.tz)
++                format = ticket.fields.by_name(name).get('format')
++                value = self._render_time_field(req, value, format)
+             cols.append(value.encode('utf-8'))
+         writer.writerow(cols)
+         return (content.getvalue(), '%s;charset=utf-8' % mimetype)
+@@ -1196,6 +1201,8 @@
+             # Shouldn't happen in "normal" circumstances, hence not a warning
+             raise InvalidTicket(_("Invalid comment threading identifier"))
+ 
++        # FIXME: Validate time field content
++
+         # Custom validation rules
+         for manipulator in self.ticket_manipulators:
+             for field, message in manipulator.validate_ticket(req, ticket):
+@@ -1362,7 +1369,7 @@
+             type_ = field['type']
+  
+             # enable a link to custom query for all choice fields
+-            if type_ not in ['text', 'textarea']:
++            if type_ not in ['text', 'textarea', 'time']:
+                 field['rendered'] = self._query_link(req, name, ticket[name])
+ 
+             # per field settings
+@@ -1437,6 +1444,16 @@
+                     field['rendered'] = \
+                         format_to_html(self.env, context, ticket[name],
+                                 escape_newlines=self.must_preserve_newlines)
++            elif type_ == 'time':
++                value = ticket[name]
++                format = field.get('format', 'datetime')
++                field['rendered'] = self._render_time_field(req, value, format,
++                                                            relative=True)
++                field['edit'] = self._render_time_field(req, value, format)
++                if format == 'date':
++                    field['format_hint'] = get_date_format_hint()
++                else:
++                    field['format_hint'] = get_datetime_format_hint()
+             
+             # ensure sane defaults
+             field.setdefault('optional', False)
+@@ -1449,7 +1466,7 @@
+             fields.remove(owner_field)
+             fields.append(owner_field)
+         return fields
+-        
++
+     def _insert_ticket_data(self, req, ticket, data, author_id, field_changes):
+         """Insert ticket data into the template `data`"""
+         replyto = req.args.get('replyto')
+@@ -1602,16 +1619,16 @@
+                                                   resource_new)
+             if rendered:
+                 changes['rendered'] = rendered
++            elif ticket.fields.by_name(field, {}).get('type') == 'time':
++                format = ticket.fields.by_name(field).get('format')
++                changes['old'] = self._render_time_field(req, old, format)
++                changes['new'] = self._render_time_field(req, new, format)
+ 
+     def _render_property_diff(self, req, ticket, field, old, new, 
+                               resource_new=None):
+         rendered = None
+         # per type special rendering of diffs
+-        type_ = None
+-        for f in ticket.fields:
+-            if f['name'] == field:
+-                type_ = f['type']
+-                break
++        type_ = ticket.fields.by_name(field, {}).get('type')
+         if type_ == 'checkbox':
+             rendered = _("set") if new == '1' else _("unset")
+         elif type_ == 'textarea':
+@@ -1662,6 +1679,17 @@
+                                 old=tag.em(old), new=tag.em(new))
+         return rendered
+ 
++    def _render_time_field(self, req, value, format, relative=False):
++        format = format or 'datetime'
++        if format == 'age' and relative:
++            return pretty_timedelta(value) if value else ''
++        elif format == 'date':
++            return format_date(value, '%Y-%m-%d', tzinfo=req.tz) \
++                   if value else ''
++        else:
++            return format_datetime(value, '%Y-%m-%d %H:%M:%S', tzinfo=req.tz) \
++                   if value else ''
++
+     def grouped_changelog_entries(self, ticket, db=None, when=None):
+         """Iterate on changelog entries, consolidating related changes
+         in a `dict` object.
+diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py
+--- a/trac/util/datefmt.py
++++ b/trac/util/datefmt.py
+@@ -513,10 +513,12 @@
+     t = tzinfo.localize(datetime(*(values[k] for k in 'yMdhms')))
+     return tzinfo.normalize(t)
+ 
+-_REL_TIME_RE = re.compile(
+-    r'(\d+\.?\d*)\s*'
+-    r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*'
+-    r'(?:ago)?$')
++_REL_FUTURE_RE = re.compile(
++    r'(?:in|\+)\s*(\d+\.?\d*)\s*'
++    r'(second|minute|hour|day|week|month|year|[hdwmy])s?$')
++_REL_PAST_RE = re.compile(
++    r'(?:-\s*)?(\d+\.?\d*)\s*'
++    r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*(?:ago)?$')
+ _time_intervals = dict(
+     second=lambda v: timedelta(seconds=v),
+     minute=lambda v: timedelta(minutes=v),
+@@ -531,7 +533,7 @@
+     m=lambda v: timedelta(days=30 * v),
+     y=lambda v: timedelta(days=365 * v),
+ )
+-_TIME_START_RE = re.compile(r'(this|last)\s*'
++_TIME_START_RE = re.compile(r'(this|last|next)\s*'
+                             r'(second|minute|hour|day|week|month|year)$')
+ _time_starts = dict(
+     second=lambda now: now.replace(microsecond=0),
+@@ -543,7 +545,7 @@
+     month=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0,
+                                   day=1),
+     year=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0,
+-                                  day=1, month=1),
++                                 day=1, month=1),
+ )
+ 
+ def _parse_relative_time(text, tzinfo):
+@@ -555,13 +557,20 @@
+     if text == 'yesterday':
+         return now.replace(microsecond=0, second=0, minute=0, hour=0) \
+                - timedelta(days=1)
+-    match = _REL_TIME_RE.match(text)
++    if text == 'tomorrow':
++        return now.replace(microsecond=0, second=0, minute=0, hour=0) \
++               + timedelta(days=1)
++    match = _REL_FUTURE_RE.match(text)
+     if match:
+-        (value, interval) = match.groups()
++        value, interval = match.groups()
++        return now + _time_intervals[interval](float(value))
++    match = _REL_PAST_RE.match(text)
++    if match:
++        value, interval = match.groups()
+         return now - _time_intervals[interval](float(value))
+     match = _TIME_START_RE.match(text)
+     if match:
+-        (which, start) = match.groups()
++        which, start = match.groups()
+         dt = _time_starts[start](now)
+         if which == 'last':
+             if start == 'month':
+@@ -571,8 +580,15 @@
+                     dt = dt.replace(year=dt.year - 1, month=12)
+             else:
+                 dt -= _time_intervals[start](1)
++        elif which == 'next':
++            if start == 'month':
++                if dt.month < 12:
++                    dt = dt.replace(month=dt.month + 1)
++                else:
++                    dt = dt.replace(year=dt.year + 1, month=1)
++            else:
++                dt += _time_intervals[start](1)
+         return dt
+-    return None
+ 
+ # -- formatting/parsing helper functions
+ 

datefmt-err-to-warn.patch

+# HG changeset patch
+# Parent e75d9104b1af854ddeeb08070adbcd39fdc4ec53
+Handle invalid time input more gracefully.
+
+diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
+--- a/trac/ticket/web_ui.py
++++ b/trac/ticket/web_ui.py
+@@ -706,8 +706,16 @@
+             fields.pop('checkbox_' + each, None)    # See Ticket.populate()
+         for field, value in fields.iteritems():
+             if field in ticket.time_fields:
+-                fields[field] = user_time(req, parse_date, value) \
+-                                if value else None
++                try:
++                    fields[field] = user_time(req, parse_date, value) \
++                                    if value else None
++                except TracError, e:
++                    # Handle bad user input for custom time fields gracefully.
++                    if field in ticket.custom_fields:
++                        # Leave it to _validate_ticket() to complain.
++                        fields[field] = value
++                    else:
++                        raise TracError(e)
+         ticket.populate(fields)
+         # special case for updating the Cc: field
+         if 'cc_update' in req.args:
+@@ -1202,7 +1210,19 @@
+             # Shouldn't happen in "normal" circumstances, hence not a warning
+             raise InvalidTicket(_("Invalid comment threading identifier"))
+ 
+-        # FIXME: Validate time field content
++        # Validate time field content
++        for field in ticket.time_fields:
++            value = ticket[field]
++            if not (field in ticket.std_fields or \
++                    isinstance(value, datetime)):
++                try:
++                    ticket.values[field] = user_time(req, parse_date, value) \
++                                          if value else None
++                except TracError, e:
++                    # Degrade TracError to warning.
++                    add_warning(req, e)
++                    ticket.values[field] = value
++                    valid = False
+ 
+         # Custom validation rules
+         for manipulator in self.ticket_manipulators:
+@@ -1681,6 +1701,9 @@
+         return rendered
+ 
+     def _render_time_field(self, req, value, format, relative=False):
++        if value and not isinstance(value, datetime):
++            # Return invalid timestamps unchanged.
++            return u'' if 'None' else unicode(value)
+         format = format or 'datetime'
+         if format == 'age' and relative:
+             return pretty_timedelta(value) if value else ''
+# HG changeset patch
+# Parent 9182daf0d7da44977b96cbb5039016790c902d38
+Fix various msgids.
+
+diff --git a/trac/ticket/templates/ticket.html b/trac/ticket/templates/ticket.html
+--- a/trac/ticket/templates/ticket.html
++++ b/trac/ticket/templates/ticket.html
+@@ -165,7 +165,8 @@
+             <a id="edit" onfocus="$('#comment').get(0).focus()">Add a comment</a>
+           </h2>
+           <div id="trac-edit-warning" class="warning system-message"
+-               style="${'display: none' if start_time == ticket['changetime'] else None}">
++               style="${'display: none' if start_time == ticket['changetime'] else None}"
++               i18n:msg="">
+             This ticket has been modified since you started editing. You should review the
+             <em class="trac-new">other modifications</em> which have been appended above,
+             and any <em class="trac-conflict">conflicts</em> shown in the preview below.
+diff --git a/trac/versioncontrol/templates/changeset.html b/trac/versioncontrol/templates/changeset.html
+--- a/trac/versioncontrol/templates/changeset.html
++++ b/trac/versioncontrol/templates/changeset.html
+@@ -137,7 +137,7 @@
+           <dd class="time" py:choose="datetime.now(utc) - changeset.date &lt; timedelta(0, 3600)">
+              ${format_datetime(changeset.date)}
+              <py:when test="True">(less than one hour ago)</py:when>
+-             <py:otherwise>(${dateinfo(changeset.date)} ago)</py:otherwise>
++             <py:otherwise><span i18n:msg="elapsedtime">(${dateinfo(changeset.date)} ago)</span></py:otherwise>
+           </dd>
+           <dt class="property author">Author:</dt>
+           <dd class="author">${authorinfo(changeset.author)}</dd>
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.