Commits

Peter Suter  committed 30fbf0c

Rework patches for review, updated for trunk@10952, use format hint locale, minor cleanup.

Reworked patches for easier review, separating out unrelated changes and collapsing all core custom-time-field changes.
Use the user's locale to get the correct format hints.
Share time formatting code.
Various minor cleanup, simplifications, renames etc.

  • Participants
  • Parent commits 27f0dcb

Comments (0)

Files changed (7)

File MinorStyleCleanup.patch

+# HG changeset patch
+# Parent 326669ef4fe13ecfcaa11d75d0ca33268f4916f8
+Minor style cleanups
+
+diff -r 326669ef4fe1 trac/ticket/templates/ticket.html
+--- a/trac/ticket/templates/ticket.html	Mon Jan 30 07:14:54 2012 +0000
++++ b/trac/ticket/templates/ticket.html	Sat Feb 04 19:39:49 2012 +0100
+@@ -270,18 +270,18 @@
+                             <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 -r 326669ef4fe1 trac/ticket/web_ui.py
+--- a/trac/ticket/web_ui.py	Mon Jan 30 07:14:54 2012 +0000
++++ b/trac/ticket/web_ui.py	Sat Feb 04 19:39:49 2012 +0100
+@@ -1449,7 +1449,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')
+diff -r 326669ef4fe1 trac/util/datefmt.py
+--- a/trac/util/datefmt.py	Mon Jan 30 07:14:54 2012 +0000
++++ b/trac/util/datefmt.py	Sat Feb 04 19:39:49 2012 +0100
+@@ -545,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):
+@@ -559,11 +559,11 @@
+                - timedelta(days=1)
+     match = _REL_TIME_RE.match(text)
+     if match:
+-        (value, interval) = match.groups()
++        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':
+@@ -574,7 +574,6 @@
+             else:
+                 dt -= _time_intervals[start](1)
+         return dt
+-    return None
+ 
+ # -- formatting/parsing helper functions
+ 

File TicketFieldList.patch

+# HG changeset patch
+# Parent 22b44e423a789dfe04afdcbafe01d17848b32727
+Simplify ticket field access
+
+diff -r 22b44e423a78 trac/ticket/api.py
+--- a/trac/ticket/api.py	Sat Feb 04 20:40:14 2012 +0100
++++ b/trac/ticket/api.py	Sat Feb 04 20:40:21 2012 +0100
+@@ -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',
+@@ -334,7 +356,7 @@
+         fields.append({'name': 'changetime', 'type': 'time',
+                        '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,9 +383,9 @@
+     @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,
+@@ -366,8 +387,9 @@
+         config = self.ticket_custom_section
+         for name in [option for option, value in config.options()
+                      if '.' not in option]:
+             field = {
+                 'name': name,
++                'custom': True,
+                 'type': config.get(name),
+                 'order': config.getint(name + '.order', 0),
+                 'label': config.get(name + '.label') or name.capitalize(),
+diff -r 22b44e423a78 trac/ticket/model.py
+--- a/trac/ticket/model.py	Sat Feb 04 20:40:14 2012 +0100
++++ b/trac/ticket/model.py	Sat Feb 04 20:40:21 2012 +0100
+@@ -153,8 +153,7 @@
+         if value:
+             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 +164,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
+ 
+diff -r 22b44e423a78 trac/ticket/query.py
+--- a/trac/ticket/query.py	Sat Feb 04 20:40:14 2012 +0100
++++ b/trac/ticket/query.py	Sat Feb 04 20:40:21 2012 +0100
+@@ -316,10 +316,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))
+@@ -711,8 +708,6 @@
+ 
+         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')),
+@@ -716,7 +711,7 @@
+ 
+         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,5 +1066,5 @@
+                 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:
+@@ -1075,5 +1070,5 @@
+         if owner_field:
+-            TicketSystem(self.env).eventually_restrict_owner(owner_field[0])
++            TicketSystem(self.env).eventually_restrict_owner(owner_field)
+         data = query.template_data(context, tickets, orig_list, orig_time, req)
+ 
+         req.session['query_href'] = query.get_href(context.href)
+diff -r 22b44e423a78 trac/ticket/templates/query_results.html
+--- a/trac/ticket/templates/query_results.html	Sat Feb 04 20:40:14 2012 +0100
++++ b/trac/ticket/templates/query_results.html	Sat Feb 04 20:40:21 2012 +0100
+@@ -80,7 +80,8 @@
+                       <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 -r 22b44e423a78 trac/ticket/web_ui.py
+--- a/trac/ticket/web_ui.py	Sat Feb 04 20:40:14 2012 +0100
++++ b/trac/ticket/web_ui.py	Sat Feb 04 20:40:21 2012 +0100
+@@ -1607,11 +1607,7 @@
+                               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':
+diff -r ae87f4ee20f0 trac/ticket/tests/api.py
+--- a/trac/ticket/tests/api.py	Sat Feb 04 19:43:44 2012 +0100
++++ b/trac/ticket/tests/api.py	Sat Feb 04 19:44:45 2012 +0100
+@@ -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):

File 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),

File 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

File 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
-.
+# Parent 1628a2734e25e8406dbec62cef693665e77cada8
+Support custom time fields:
+Convert between DB time strings and datetime objects in the model.
+Parse ticket time input to datetime objects.
+Add format hint to ticket time input controls.
+Add a `.format` attribute to custom time fields to allow rendering as an age, a date or a datetime.
+Render time fields in ticket box, ticket csv export and ticket changelog.
+Render time fields in ticket queries.
+Render time fields in notification emails.
+Support parsing relative time values in the future (e.g. ''tomorrow'', ''next month'' or ''in 3 hours'').
 
-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 @@
+diff -r 1628a2734e25 trac/ticket/api.py
+--- a/trac/ticket/api.py	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/ticket/api.py	Sun Feb 05 15:11:38 2012 +0100
+@@ -351,9 +351,9 @@
          fields.append({'name': 'cc', 'type': 'text', 'label': N_('Cc')})
  
          # Date/time fields
 +        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 @@
+         for field in self.custom_fields:
+@@ -406,6 +406,8 @@
                  field['format'] = config.get(name + '.format', 'plain')
                  field['width'] = config.getint(name + '.cols')
                  field['height'] = config.getint(name + '.rows')
              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
+diff -r 1628a2734e25 trac/ticket/model.py
+--- a/trac/ticket/model.py	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/ticket/model.py	Sun Feb 05 15:11:38 2012 +0100
 @@ -28,7 +28,8 @@
  from trac.ticket.api import TicketSystem
  from trac.util import embedded_numbers, partition
  from trac.util.translation import _
  
  __all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity',
-@@ -44,6 +45,25 @@
+@@ -44,6 +45,28 @@
      return ', '.join(cclist)
  
  
-+def _str_to_datetime(value):
++def _db_str_to_datetime(value):
 +    if value is None:
 +        return None
 +    try:
 +        return None
 +
 +
-+def _datetime_to_str(dt):
-+    if dt:
++def _datetime_to_db_str(dt, is_custom_field):
++    if not dt:
++        return ''
++    if is_custom_field:
 +        return str(to_utimestamp(dt))
-+    return ''
++    else:
++        return to_utimestamp(dt)
 +
 +
  class Ticket(object):
  
      # Fields that must not be modified directly by the user
-@@ -133,7 +153,9 @@
+@@ -133,7 +156,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)
++                    self.values[name] = _db_str_to_datetime(value)
 +                elif value is None:
                      self.values[name] = empty
                  else:
                      self.values[name] = value
-@@ -150,11 +172,10 @@
+@@ -150,7 +175,7 @@
              self._old[name] = self.values.get(name)
          elif self._old[name] == value: # Change of field reverted
              del self._old[name]
 +        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 @@
+             if self.fields.by_name(name, {}).get('type') != 'textarea':
+@@ -172,7 +197,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]:
  
          # 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 @@
+@@ -211,10 +236,7 @@
              self['owner'] = default_to_owner
  
          # Perform type conversions
 -        for field in self.time_fields:
 -            if field in values:
 -                values[field] = to_utimestamp(values[field])
-+        values = self._to_db_types(self.values)
++        db_values = self._to_db_types(self.values)
  
          # Insert ticket record
          std_fields = []
-@@ -241,9 +257,9 @@
+@@ -231,16 +253,16 @@
+             cursor.execute("INSERT INTO ticket (%s) VALUES (%s)"
+                            % (','.join(std_fields),
+                               ','.join(['%s'] * len(std_fields))),
+-                           [values[name] for name in std_fields])
++                           [db_values[name] for name in std_fields])
+             tkt_id = db.get_last_id(cursor, 'ticket')
+ 
+             # Insert custom fields
              if custom_fields:
                  db.executemany(
                      """INSERT INTO ticket_custom (ticket, name, value)
 -                      """,
 -                    [(tkt_id, c, self[c]) for c in custom_fields])
 +                       VALUES (%s, %s, %s)
-+                       """, [(tkt_id, c, values.get(c, ''))
++                       """, [(tkt_id, c, db_values.get(c, ''))
 +                             for c in custom_fields])
  
          self.id = tkt_id
          self.resource = self.resource(id=tkt_id)
-@@ -297,6 +313,10 @@
+@@ -294,6 +316,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)
++        db_values = self._to_db_types(self.values)
++        old_db_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 @@
+@@ -327,20 +353,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))
++                              """, (db_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, '')))
++                              """, (self.id, name, db_values.get(name, '')))
                  else:
                      db("UPDATE ticket SET %s=%%s WHERE id=%%s" 
 -                       % name, (self[name], self.id))
-+                       % name, (values.get(name, ''), self.id))
++                       % name, (db_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, '')))
++                      """, (self.id, when_ts, author, name, old_db_values[name],
++                            db_values.get(name, '')))
  
              # always save comment, even if empty 
              # (numbering support for timeline)
-@@ -360,6 +380,16 @@
+@@ -357,6 +383,14 @@
              listener.ticket_changed(self, comment, author, old_values)
          return int(cnum.rsplit('.', 1)[-1])
  
 +        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)
++                is_custom_field = field in self.custom_fields
++                values[field] = _datetime_to_db_str(value, is_custom_field)
 +        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 @@
+@@ -400,10 +434,15 @@
                  ORDER BY time,permanent,author
                  """
              args = (self.id, sid, sid)
 +        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)
++                oldvalue = _db_str_to_datetime(oldvalue)
++                newvalue = _db_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
+diff -r 1628a2734e25 trac/ticket/notification.py
+--- a/trac/ticket/notification.py	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/ticket/notification.py	Sun Feb 05 15:11:38 2012 +0100
 @@ -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, \
++from trac.util.datefmt import pretty_format_time, get_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 @@
+@@ -161,7 +162,11 @@
+                     else:
                          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)
+                             old = self.obfuscate_email(old)
+-                            new = self.obfuscate_email(new)
++                            new = self.obfuscate_email(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)
 +                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)
+                 fval = self.obfuscate_email(fval)
              if f['type'] == 'textarea' or '\n' in unicode(fval):
-@@ -320,6 +331,16 @@
+@@ -320,6 +331,10 @@
          
          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 ''
++        tzinfo = get_timezone(self.config.get('trac', 'default_timezone'))
++        return pretty_format_time(None, value, format, tzinfo=tzinfo)
 +
      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
+diff -r 1628a2734e25 trac/ticket/query.py
+--- a/trac/ticket/query.py	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/ticket/query.py	Sun Feb 05 15:11:38 2012 +0100
 @@ -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, \
++from trac.util.datefmt import from_utimestamp, parse_date, \
++                              pretty_format_time, 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 @@
+@@ -331,7 +332,7 @@
                          if href is not None:
                              result['href'] = href.ticket(val)
                      elif name in self.time_fields:
                      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 @@
+@@ -1136,8 +1137,8 @@
                          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 ''
++                        value = pretty_format_time(req, value, format)
                      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 @@
+diff -r 1628a2734e25 trac/ticket/templates/query_results.html
+--- a/trac/ticket/templates/query_results.html	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/ticket/templates/query_results.html	Sun Feb 05 15:11:38 2012 +0100
+@@ -75,7 +75,7 @@
                          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="isinstance(value, datetime)">${pretty_dateinfo(value, dateonly=True)}</py:when>
++                      <py:when test="header.field.type == 'time'">${pretty_format_time(req, value, header.field.format, relative=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 @@
+diff -r 1628a2734e25 trac/ticket/templates/ticket.html
+--- a/trac/ticket/templates/ticket.html	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/ticket/templates/ticket.html	Sun Feb 05 15:11:38 2012 +0100
+@@ -266,6 +266,8 @@
                                   checked="${value == option or None}" />
                            ${option}
                          </label>
                          <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
+diff -r 1628a2734e25 trac/ticket/web_ui.py
+--- a/trac/ticket/web_ui.py	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/ticket/web_ui.py	Sun Feb 05 15:11:38 2012 +0100
 @@ -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.datefmt import (pretty_format_time, from_utimestamp,
++                               get_date_format_hint, get_datetime_format_hint,
++                               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
-@@ -702,6 +703,10 @@
+@@ -702,6 +703,18 @@
          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
++                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:
-@@ -1056,8 +1061,8 @@
+@@ -1056,8 +1069,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)
++                value = pretty_format_time(req, value, format)
              cols.append(value.encode('utf-8'))
          writer.writerow(cols)
          return (content.getvalue(), '%s;charset=utf-8' % mimetype)
-@@ -1196,6 +1201,8 @@
+@@ -1196,6 +1209,22 @@
              # 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:
++                    format = ticket.fields.by_name(field).get('format')
++                    ticket.values[field] = user_time(req, parse_date, value,
++                                                     hint=format) \
++                                          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:
              for field, message in manipulator.validate_ticket(req, ticket):
-@@ -1362,7 +1369,7 @@
+@@ -1362,7 +1391,7 @@
              type_ = field['type']
   
              # enable a link to custom query for all choice fields
                  field['rendered'] = self._query_link(req, name, ticket[name])
  
              # per field settings
-@@ -1437,6 +1444,16 @@
+@@ -1437,6 +1466,17 @@
                      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,
++                field['rendered'] = pretty_format_time(req, value, format,
 +                                                            relative=True)
-+                field['edit'] = self._render_time_field(req, value, format)
++                field['edit'] = pretty_format_time(req, value, format)
++                locale = getattr(req, 'lc_time', None)
 +                if format == 'date':
-+                    field['format_hint'] = get_date_format_hint()
++                    field['format_hint'] = get_date_format_hint(locale)
 +                else:
-+                    field['format_hint'] = get_datetime_format_hint()
++                    field['format_hint'] = get_datetime_format_hint(locale)
              
              # 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 @@
+@@ -1602,6 +1642,10 @@
                                                    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)
++                changes['old'] = pretty_format_time(req, old, format)
++                changes['new'] = pretty_format_time(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
+diff -r 1628a2734e25 trac/util/datefmt.py
+--- a/trac/util/datefmt.py	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/util/datefmt.py	Sun Feb 05 15:11:38 2012 +0100
+@@ -298,6 +298,23 @@
+         weekdays[t.weekday()], t.day, months[t.month - 1], t.year,
+         t.hour, t.minute, t.second)
  
-+    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 @@
++def pretty_format_time(req, value, format, relative=False, *args, **kwargs):
++    """Format a `datetime` object `value` depending on `format` which can be:
++    'date', 'datetime' or 'age' (if `relative` is `True`, otherwise fall back
++    to 'datetime').
++    The `req` is used for timezone and locale information if available.
++    """
++    if not value:
++        return u''
++    if not isinstance(value, datetime):
++        # Return invalid timestamps unchanged.
++        return unicode(value)
++    if format == 'age' and relative:
++        return pretty_timedelta(value) 
++    elif format == 'date':
++        return user_time(req, format_date, value)
++    else:
++        return user_time(req, format_datetime, value)
+ 
+ # -- parsing
+ 
+@@ -358,7 +375,8 @@
+         dt = _parse_relative_time(text, tzinfo)
+     if dt is None:
+         hint = {'datetime': get_datetime_format_hint,
+-                'date': get_date_format_hint
++                'date': get_date_format_hint,
++                'age': get_datetime_format_hint
+                }.get(hint, lambda(l): hint)(locale)
+         raise TracError(_('"%(date)s" is an invalid date, or the date format '
+                           'is not known. Try "%(hint)s" instead.', 
+@@ -515,10 +533,12 @@
      t = tzinfo.localize(datetime(*(values[k] for k in 'yMdhms')))
      return tzinfo.normalize(t)
  
  _time_intervals = dict(
      second=lambda v: timedelta(seconds=v),
      minute=lambda v: timedelta(minutes=v),
-@@ -531,7 +533,7 @@
+@@ -533,7 +553,7 @@
      m=lambda v: timedelta(days=30 * v),
      y=lambda v: timedelta(days=365 * v),
  )
                              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 @@
+@@ -557,8 +577,15 @@
      if text == 'yesterday':
          return now.replace(microsecond=0, second=0, minute=0, hour=0) \
                 - timedelta(days=1)
 +               + 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()
++        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 @@
+@@ -573,6 +600,14 @@
                      dt = dt.replace(year=dt.year - 1, month=12)
              else:
                  dt -= _time_intervals[start](1)
 +            else:
 +                dt += _time_intervals[start](1)
          return dt
--    return None
  
  # -- formatting/parsing helper functions
+diff -r 1628a2734e25 trac/web/chrome.py
+--- a/trac/web/chrome.py	Sun Feb 05 10:01:25 2012 +0100
++++ b/trac/web/chrome.py	Sun Feb 05 15:11:38 2012 +0100
+@@ -54,9 +54,9 @@
+ from trac.util.text import pretty_size, obfuscate_email_address, \
+                            shorten_line, unicode_quote_plus, to_unicode, \
+                            javascript_quote, exception_to_unicode
+-from trac.util.datefmt import pretty_timedelta, format_datetime, format_date, \
+-                              format_time, from_utimestamp, http_date, utc, \
+-                              user_time
++from trac.util.datefmt import pretty_timedelta, pretty_format_time, \
++                              format_datetime, format_date, format_time, \
++                              from_utimestamp, http_date, utc, user_time
+ from trac.util.translation import _, get_available_locales
+ from trac.web.api import IRequestHandler, ITemplateStreamFilter, HTTPNotFound
+ from trac.web.href import Href
+@@ -919,6 +919,7 @@
+             'fromtimestamp': partial(datetime.datetime.fromtimestamp,
+                                      tz=req and req.tz),
+             'from_utimestamp': from_utimestamp,
++            'pretty_format_time': pretty_format_time,
  
+             # Wiki-formatting functions
+             'wiki_to': partial(format_to, self.env),

File 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 ''
+MinorStyleCleanup.patch
+fx-msgids.patch
+TicketFieldList.patch
 customtimefields.patch
-ct-restrict-owner-fix.patch
-fx-msgids.patch
-complete-usertime.patch
-datefmt-err-to-warn.patch
 ct-tests.patch