# HG changeset patch
# Parent 3e22fab66b4f204eab1db2ae28b8cfeb968b041e
Add code from the stagnant private branch in rblank's Hg repo for
TicketCustomTimeFields support, see
https://bitbucket.org/rblank/trac/compare/ticket-1942..jquery-ui-datetimepicker
.
diff --git a/trac/ticket/api.py b/trac/ticket/api.py
|
a
|
b
|
|
| 30 | 30 | from trac.wiki import IWikiSyntaxProvider, WikiParser |
| 31 | 31 | |
| 32 | 32 | |
| | 33 | class TicketFieldList(list): |
| | 34 | """Improved ticket field list, allowing access by name.""" |
| | 35 | __slots__ = ['_map'] |
| | 36 | |
| | 37 | def __init__(self, *args): |
| | 38 | super(TicketFieldList, self).__init__(*args) |
| | 39 | self._map = dict((value['name'], value) for value in self) |
| | 40 | |
| | 41 | def append(self, value): |
| | 42 | super(TicketFieldList, self).append(value) |
| | 43 | self._map[value['name']] = value |
| | 44 | |
| | 45 | def by_name(self, name, default=None): |
| | 46 | return self._map.get(name, default) |
| | 47 | |
| | 48 | def __copy__(self): |
| | 49 | return TicketFieldList(self) |
| | 50 | |
| | 51 | def __deepcopy__(self, memo): |
| | 52 | return TicketFieldList(copy.deepcopy(value, memo) for value in self) |
| | 53 | |
| | 54 | |
| 33 | 55 | class ITicketActionController(Interface): |
| 34 | 56 | """Extension point interface for components willing to participate |
| 35 | 57 | in the ticket workflow. |
| … |
… |
|
| 280 | 302 | """Return the list of fields available for tickets.""" |
| 281 | 303 | from trac.ticket import model |
| 282 | 304 | |
| 283 | | fields = [] |
| | 305 | fields = TicketFieldList() |
| 284 | 306 | |
| 285 | 307 | # Basic text fields |
| 286 | 308 | fields.append({'name': 'summary', 'type': 'text', |
| … |
… |
|
| 329 | 351 | fields.append({'name': 'cc', 'type': 'text', 'label': N_('Cc')}) |
| 330 | 352 | |
| 331 | 353 | # Date/time fields |
| 332 | | fields.append({'name': 'time', 'type': 'time', |
| | 354 | fields.append({'name': 'time', 'type': 'time', 'format': 'age', |
| 333 | 355 | 'label': N_('Created')}) |
| 334 | | fields.append({'name': 'changetime', 'type': 'time', |
| | 356 | fields.append({'name': 'changetime', 'type': 'time', 'format': 'age', |
| 335 | 357 | 'label': N_('Modified')}) |
| 336 | 358 | |
| 337 | | for field in self.get_custom_fields(): |
| | 359 | for field in self.custom_fields: |
| 338 | 360 | if field['name'] in [f['name'] for f in fields]: |
| 339 | 361 | self.log.warning('Duplicate field name "%s" (ignoring)', |
| 340 | 362 | field['name']) |
| … |
… |
|
| 347 | 369 | self.log.warning('Invalid name for custom field: "%s" ' |
| 348 | 370 | '(ignoring)', field['name']) |
| 349 | 371 | continue |
| 350 | | field['custom'] = True |
| 351 | 372 | fields.append(field) |
| 352 | 373 | |
| 353 | 374 | return fields |
| … |
… |
|
| 362 | 383 | @cached |
| 363 | 384 | def custom_fields(self, db): |
| 364 | 385 | """Return the list of custom ticket fields available for tickets.""" |
| 365 | | fields = [] |
| | 386 | fields = TicketFieldList() |
| 366 | 387 | config = self.ticket_custom_section |
| 367 | 388 | for name in [option for option, value in config.options() |
| 368 | 389 | if '.' not in option]: |
| 369 | 390 | field = { |
| 370 | | 'name': name, |
| | 391 | 'name': name, 'custom': True, |
| 371 | 392 | 'type': config.get(name), |
| 372 | 393 | 'order': config.getint(name + '.order', 0), |
| 373 | 394 | 'label': config.get(name + '.label') or name.capitalize(), |
| … |
… |
|
| 384 | 405 | field['format'] = config.get(name + '.format', 'plain') |
| 385 | 406 | field['width'] = config.getint(name + '.cols') |
| 386 | 407 | field['height'] = config.getint(name + '.rows') |
| | 408 | elif field['type'] == 'time': |
| | 409 | field['format'] = config.get(name + '.format', 'datetime') |
| 387 | 410 | fields.append(field) |
| 388 | 411 | |
| 389 | 412 | fields.sort(lambda x, y: cmp((x['order'], x['name']), |
diff --git a/trac/ticket/model.py b/trac/ticket/model.py
|
a
|
b
|
|
| 28 | 28 | from trac.ticket.api import TicketSystem |
| 29 | 29 | from trac.util import embedded_numbers, partition |
| 30 | 30 | from trac.util.text import empty |
| 31 | | from trac.util.datefmt import from_utimestamp, to_utimestamp, utc, utcmax |
| | 31 | from trac.util.datefmt import from_utimestamp, parse_date, to_utimestamp, \ |
| | 32 | utc, utcmax |
| 32 | 33 | from trac.util.translation import _ |
| 33 | 34 | |
| 34 | 35 | __all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity', |
| … |
… |
|
| 44 | 45 | return ', '.join(cclist) |
| 45 | 46 | |
| 46 | 47 | |
| | 48 | def _str_to_datetime(value): |
| | 49 | if value is None: |
| | 50 | return None |
| | 51 | try: |
| | 52 | return from_utimestamp(long(value)) |
| | 53 | except ValueError: |
| | 54 | pass |
| | 55 | try: |
| | 56 | return parse_date(value.strip(), utc, 'datetime') |
| | 57 | except Exception: |
| | 58 | return None |
| | 59 | |
| | 60 | |
| | 61 | def _datetime_to_str(dt): |
| | 62 | if dt: |
| | 63 | return str(to_utimestamp(dt)) |
| | 64 | return '' |
| | 65 | |
| | 66 | |
| 47 | 67 | class Ticket(object): |
| 48 | 68 | |
| 49 | 69 | # Fields that must not be modified directly by the user |
| … |
… |
|
| 133 | 153 | SELECT name, value FROM ticket_custom WHERE ticket=%s |
| 134 | 154 | """, (tkt_id,)): |
| 135 | 155 | if name in self.custom_fields: |
| 136 | | if value is None: |
| | 156 | if name in self.time_fields: |
| | 157 | self.values[name] = _str_to_datetime(value) |
| | 158 | elif value is None: |
| 137 | 159 | self.values[name] = empty |
| 138 | 160 | else: |
| 139 | 161 | self.values[name] = value |
| … |
… |
|
| 150 | 172 | self._old[name] = self.values.get(name) |
| 151 | 173 | elif self._old[name] == value: # Change of field reverted |
| 152 | 174 | del self._old[name] |
| 153 | | if value: |
| | 175 | if value and name not in self.time_fields: |
| 154 | 176 | if isinstance(value, list): |
| 155 | 177 | raise TracError(_("Multi-values fields not supported yet")) |
| 156 | | field = [field for field in self.fields if field['name'] == name] |
| 157 | | if field and field[0].get('type') != 'textarea': |
| | 178 | if self.fields.by_name(name, {}).get('type') != 'textarea': |
| 158 | 179 | value = value.strip() |
| 159 | 180 | self.values[name] = value |
| 160 | 181 | |
| … |
… |
|
| 165 | 186 | value = self.values[name] |
| 166 | 187 | if value is not empty: |
| 167 | 188 | return value |
| 168 | | field = [field for field in self.fields if field['name'] == name] |
| 169 | | if field: |
| 170 | | return field[0].get('value', '') |
| | 189 | return self.fields.by_name(name, {}).get('value', '') |
| 171 | 190 | except KeyError: |
| 172 | 191 | pass |
| 173 | 192 | |
| … |
… |
|
| 175 | 194 | """Populate the ticket with 'suitable' values from a dictionary""" |
| 176 | 195 | field_names = [f['name'] for f in self.fields] |
| 177 | 196 | for name in [name for name in values.keys() if name in field_names]: |
| 178 | | self[name] = values.get(name, '') |
| | 197 | self[name] = values[name] |
| 179 | 198 | |
| 180 | 199 | # We have to do an extra trick to catch unchecked checkboxes |
| 181 | 200 | for name in [name for name in values.keys() if name[9:] in field_names |
| … |
… |
|
| 214 | 233 | self['owner'] = default_to_owner |
| 215 | 234 | |
| 216 | 235 | # Perform type conversions |
| 217 | | values = dict(self.values) |
| 218 | | for field in self.time_fields: |
| 219 | | if field in values: |
| 220 | | values[field] = to_utimestamp(values[field]) |
| | 236 | values = self._to_db_types(self.values) |
| 221 | 237 | |
| 222 | 238 | # Insert ticket record |
| 223 | 239 | std_fields = [] |
| … |
… |
|
| 241 | 257 | if custom_fields: |
| 242 | 258 | db.executemany( |
| 243 | 259 | """INSERT INTO ticket_custom (ticket, name, value) |
| 244 | | VALUES (%s, %s, %s) |
| 245 | | """, |
| 246 | | [(tkt_id, c, self[c]) for c in custom_fields]) |
| | 260 | VALUES (%s, %s, %s) |
| | 261 | """, [(tkt_id, c, values.get(c, '')) |
| | 262 | for c in custom_fields]) |
| 247 | 263 | |
| 248 | 264 | self.id = tkt_id |
| 249 | 265 | self.resource = self.resource(id=tkt_id) |
| … |
… |
|
| 297 | 313 | # we just leave the owner as is. |
| 298 | 314 | pass |
| 299 | 315 | |
| | 316 | # Perform type conversions |
| | 317 | values = self._to_db_types(self.values) |
| | 318 | old_values = self._to_db_types(self._old) |
| | 319 | |
| 300 | 320 | with self.env.db_transaction as db: |
| 301 | 321 | db("UPDATE ticket SET changetime=%s WHERE id=%s", |
| 302 | 322 | (when_ts, self.id)) |
| … |
… |
|
| 330 | 350 | """, (self.id, name)): |
| 331 | 351 | db("""UPDATE ticket_custom SET value=%s |
| 332 | 352 | WHERE ticket=%s AND name=%s |
| 333 | | """, (self[name], self.id, name)) |
| | 353 | """, (values.get(name, ''), self.id, name)) |
| 334 | 354 | break |
| 335 | 355 | else: |
| 336 | 356 | db("""INSERT INTO ticket_custom (ticket,name,value) |
| 337 | 357 | VALUES(%s,%s,%s) |
| 338 | | """, (self.id, name, self[name])) |
| | 358 | """, (self.id, name, values.get(name, ''))) |
| 339 | 359 | else: |
| 340 | 360 | db("UPDATE ticket SET %s=%%s WHERE id=%%s" |
| 341 | | % name, (self[name], self.id)) |
| | 361 | % name, (values.get(name, ''), self.id)) |
| 342 | 362 | db("""INSERT INTO ticket_change |
| 343 | 363 | (ticket,time,author,field,oldvalue,newvalue) |
| 344 | 364 | VALUES (%s, %s, %s, %s, %s, %s) |
| 345 | | """, (self.id, when_ts, author, name, self._old[name], |
| 346 | | self[name])) |
| | 365 | """, (self.id, when_ts, author, name, old_values[name], |
| | 366 | values.get(name, ''))) |
| 347 | 367 | |
| 348 | 368 | # always save comment, even if empty |
| 349 | 369 | # (numbering support for timeline) |
| … |
… |
|
| 360 | 380 | listener.ticket_changed(self, comment, author, old_values) |
| 361 | 381 | return int(cnum.rsplit('.', 1)[-1]) |
| 362 | 382 | |
| | 383 | def _to_db_types(self, values): |
| | 384 | values = values.copy() |
| | 385 | for field, value in values.iteritems(): |
| | 386 | if field in self.time_fields: |
| | 387 | if field in self.custom_fields: |
| | 388 | values[field] = _datetime_to_str(value) |
| | 389 | else: |
| | 390 | values[field] = to_utimestamp(value) |
| | 391 | return values |
| | 392 | |
| 363 | 393 | def get_changelog(self, when=None, db=None): |
| 364 | 394 | """Return the changelog as a list of tuples of the form |
| 365 | 395 | (time, author, field, oldvalue, newvalue, permanent). |
| … |
… |
|
| 403 | 433 | ORDER BY time,permanent,author |
| 404 | 434 | """ |
| 405 | 435 | args = (self.id, sid, sid) |
| 406 | | return [(from_utimestamp(t), author, field, oldvalue or '', |
| 407 | | newvalue or '', permanent) |
| 408 | | for t, author, field, oldvalue, newvalue, permanent in |
| 409 | | self.env.db_query(sql, args)] |
| | 436 | log = [] |
| | 437 | for t, author, field, oldvalue, newvalue, permanent \ |
| | 438 | in self.env.db_query(sql, args): |
| | 439 | if field in self.time_fields: |
| | 440 | oldvalue = _str_to_datetime(oldvalue) |
| | 441 | newvalue = _str_to_datetime(newvalue) |
| | 442 | log.append((from_utimestamp(t), author, field, |
| | 443 | oldvalue or '', newvalue or '', permanent)) |
| | 444 | return log |
| 410 | 445 | |
| 411 | 446 | def delete(self, db=None): |
| 412 | 447 | """Delete the ticket. |
diff --git a/trac/ticket/notification.py b/trac/ticket/notification.py
|
a
|
b
|
|
| 26 | 26 | from trac.config import * |
| 27 | 27 | from trac.notification import NotifyEmail |
| 28 | 28 | from trac.ticket.api import TicketSystem |
| 29 | | from trac.util.datefmt import to_utimestamp |
| | 29 | from trac.util.datefmt import format_date, format_datetime, timezone, \ |
| | 30 | to_utimestamp |
| 30 | 31 | from trac.util.text import obfuscate_email_address, text_width, wrap |
| 31 | 32 | from trac.util.translation import deactivate, reactivate |
| 32 | 33 | |
| … |
… |
|
| 162 | 163 | if field in ['owner', 'reporter']: |
| 163 | 164 | old = obfuscate_email_address(old) |
| 164 | 165 | new = obfuscate_email_address(new) |
| | 166 | elif field in ticket.time_fields: |
| | 167 | format = ticket.fields.by_name(field).get('format') |
| | 168 | old = self.format_time_field(old, format) |
| | 169 | new = self.format_time_field(new, format) |
| 165 | 170 | newv = new |
| 166 | 171 | length = 7 + len(field) |
| 167 | 172 | spacer_old, spacer_new = ' ', ' ' |
| … |
… |
|
| 220 | 225 | if not fname in tkt.values: |
| 221 | 226 | continue |
| 222 | 227 | fval = tkt[fname] or '' |
| | 228 | if fname in tkt.time_fields: |
| | 229 | format = tkt.fields.by_name(fname).get('format') |
| | 230 | fval = self.format_time_field(fval, format) |
| 223 | 231 | if fval.find('\n') != -1: |
| 224 | 232 | continue |
| 225 | 233 | if fname in ['owner', 'reporter']: |
| … |
… |
|
| 253 | 261 | if not tkt.values.has_key(fname): |
| 254 | 262 | continue |
| 255 | 263 | fval = tkt[fname] or '' |
| | 264 | if fname in tkt.time_fields: |
| | 265 | format = tkt.fields.by_name(fname).get('format') |
| | 266 | fval = self.format_time_field(fval, format) |
| 256 | 267 | if fname in ['owner', 'reporter']: |
| 257 | 268 | fval = obfuscate_email_address(fval) |
| 258 | 269 | if f['type'] == 'textarea' or '\n' in unicode(fval): |
| … |
… |
|
| 320 | 331 | |
| 321 | 332 | return template.generate(**data).render('text', encoding=None).strip() |
| 322 | 333 | |
| | 334 | def format_time_field(self, value, format): |
| | 335 | try: |
| | 336 | tzinfo = timezone(self.config.get('trac', 'default_timezone')) |
| | 337 | except KeyError: |
| | 338 | tzinfo = None |
| | 339 | if format == 'date': |
| | 340 | return format_date(value, tzinfo=tzinfo) if value else '' |
| | 341 | else: |
| | 342 | return format_datetime(value, tzinfo=tzinfo) if value else '' |
| | 343 | |
| 323 | 344 | def get_recipients(self, tktid): |
| 324 | 345 | notify_reporter = self.config.getbool('notification', |
| 325 | 346 | 'always_notify_reporter') |
diff --git a/trac/ticket/query.py b/trac/ticket/query.py
|
a
|
b
|
|
| 34 | 34 | from trac.ticket.api import TicketSystem |
| 35 | 35 | from trac.ticket.model import Milestone, group_milestones |
| 36 | 36 | from trac.util import Ranges, as_bool |
| 37 | | from trac.util.datefmt import format_datetime, from_utimestamp, parse_date, \ |
| 38 | | to_timestamp, to_utimestamp, utc, user_time |
| | 37 | from trac.util.datefmt import format_date, format_datetime, from_utimestamp, \ |
| | 38 | parse_date, pretty_timedelta, to_timestamp, \ |
| | 39 | to_utimestamp, utc, user_time |
| 39 | 40 | from trac.util.presentation import Paginator |
| 40 | 41 | from trac.util.text import empty, shorten_line, quote_query_string |
| 41 | 42 | from trac.util.translation import _, tag_, cleandoc_ |
| … |
… |
|
| 316 | 317 | # self.env.log.debug("SQL: " + sql % tuple([repr(a) for a in args])) |
| 317 | 318 | cursor.execute(sql, args) |
| 318 | 319 | columns = get_column_names(cursor) |
| 319 | | fields = [] |
| 320 | | for column in columns: |
| 321 | | fields += [f for f in self.fields if f['name'] == column] or \ |
| 322 | | [None] |
| | 320 | fields = [self.fields.by_name(column, None) for column in columns] |
| 323 | 321 | results = [] |
| 324 | 322 | |
| 325 | 323 | column_indices = range(len(columns)) |
| … |
… |
|
| 334 | 332 | if href is not None: |
| 335 | 333 | result['href'] = href.ticket(val) |
| 336 | 334 | elif name in self.time_fields: |
| 337 | | val = from_utimestamp(val) |
| | 335 | val = from_utimestamp(long(val)) if val else '' |
| 338 | 336 | elif field and field['type'] == 'checkbox': |
| 339 | 337 | try: |
| 340 | 338 | val = bool(int(val)) |
| … |
… |
|
| 711 | 709 | |
| 712 | 710 | cols = self.get_columns() |
| 713 | 711 | labels = TicketSystem(self.env).get_ticket_field_labels() |
| 714 | | wikify = set(f['name'] for f in self.fields |
| 715 | | if f['type'] == 'text' and f.get('format') == 'wiki') |
| 716 | 712 | |
| 717 | 713 | headers = [{ |
| 718 | 714 | 'name': col, 'label': labels.get(col, _('Ticket')), |
| 719 | | 'wikify': col in wikify, |
| | 715 | 'field': self.fields.by_name(col, {}), |
| 720 | 716 | 'href': self.get_href(context.href, order=col, |
| 721 | 717 | desc=(col == self.order and not self.desc)) |
| 722 | 718 | } for col in cols] |
| … |
… |
|
| 1071 | 1067 | add_warning(req, error) |
| 1072 | 1068 | |
| 1073 | 1069 | context = web_context(req, 'query') |
| 1074 | | owner_field = [f for f in query.fields if f['name'] == 'owner'] |
| | 1070 | owner_field = query.fields.by_name('owner', None) |
| 1075 | 1071 | if owner_field: |
| 1076 | 1072 | TicketSystem(self.env).eventually_restrict_owner(owner_field[0]) |
| 1077 | 1073 | data = query.template_data(context, tickets, orig_list, orig_time, req) |
| … |
… |
|
| 1141 | 1137 | value = Chrome(self.env).format_emails( |
| 1142 | 1138 | context.child(ticket), value) |
| 1143 | 1139 | elif col in query.time_fields: |
| 1144 | | value = format_datetime(value, '%Y-%m-%d %H:%M:%S', |
| 1145 | | tzinfo=req.tz) |
| | 1140 | format = query.fields.by_name(col).get('format') |
| | 1141 | if format == 'age': |
| | 1142 | value = pretty_timedelta(value) if value else '' |
| | 1143 | elif format == 'date': |
| | 1144 | value = format_date(value, '%Y-%m-%d', |
| | 1145 | tzinfo=req.tz) if value else '' |
| | 1146 | else: |
| | 1147 | value = format_datetime(value, '%Y-%m-%d %H:%M:%S', |
| | 1148 | tzinfo=req.tz) \ |
| | 1149 | if value else '' |
| 1146 | 1150 | values.append(unicode(value).encode('utf-8')) |
| 1147 | 1151 | writer.writerow(values) |
| 1148 | 1152 | 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
|
b
|
|
| 75 | 75 | class="${classes(closed=result.status == 'closed')}">#$result.id</a></td> |
| 76 | 76 | <td py:otherwise="" class="$name" py:choose=""> |
| 77 | 77 | <a py:when="name == 'summary'" href="$result.href" title="View ticket">$value</a> |
| | 78 | <py:when test="isinstance(value, datetime)"> |
| | 79 | <py:choose test="header.field.format"> |
| | 80 | <py:when test="'age'">${dateinfo(value)}</py:when> |
| | 81 | <py:when test="'date'">${format_date(value, tzinfo=req.tz)}</py:when> |
| | 82 | <py:otherwise>${format_datetime(value, tzinfo=req.tz)}</py:otherwise> |
| | 83 | </py:choose> |
| | 84 | </py:when> |
| | 85 | <!--! |
| 78 | 86 | <py:when test="isinstance(value, datetime)">${pretty_dateinfo(value, dateonly=True)}</py:when> |
| | 87 | --> |
| 79 | 88 | <py:when test="name == 'reporter'">${authorinfo(value)}</py:when> |
| 80 | 89 | <py:when test="name == 'cc'">${format_emails(ticket_context, value)}</py:when> |
| 81 | 90 | <py:when test="name == 'owner' and value">${authorinfo(value)}</py:when> |
| 82 | 91 | <py:when test="name == 'milestone'"><a py:if="value" title="View milestone" href="${href.milestone(value)}">${value}</a></py:when> |
| 83 | | <py:when test="header.wikify">${wiki_to_oneliner(ticket_context, value)}</py:when> |
| | 92 | <py:when test="header.field.type == 'text' |
| | 93 | and header.field.format == 'wiki'">${wiki_to_oneliner(ticket_context, value)}</py:when> |
| 84 | 94 | <py:otherwise>$value</py:otherwise> |
| 85 | 95 | </td> |
| 86 | 96 | </py:with> |
diff --git a/trac/ticket/templates/ticket.html b/trac/ticket/templates/ticket.html
|
a
|
b
|
|
| 265 | 265 | checked="${value == option or None}" /> |
| 266 | 266 | ${option} |
| 267 | 267 | </label> |
| | 268 | <input py:when="'time'" type="text" id="field-${field.name}" title="${field.format_hint}" |
| | 269 | name="field_${field.name}" value="${field.edit}" /> |
| 268 | 270 | <py:otherwise><!--! Text input fields --> |
| 269 | 271 | <py:choose> |
| 270 | 272 | <span py:when="field.cc_entry"><!--! Special case for Cc: field --> |
| 271 | 273 | <em>${field.cc_entry}</em> |
| 272 | 274 | <input type="checkbox" id="field-cc" name="cc_update" |
| 273 | | title="This checkbox allows you to add or remove yourself from the CC list." |
| 274 | | checked="${field.cc_update}" /> |
| | 275 | title="This checkbox allows you to add or remove yourself from the CC list." |
| | 276 | checked="${field.cc_update}" /> |
| 275 | 277 | </span> |
| 276 | 278 | <!--! Cc: when TICKET_EDIT_CC is allowed --> |
| 277 | 279 | <span py:when="field.name == 'cc'"> |
| 278 | | <input type="text" id="field-${field.name}" |
| 279 | | title="Space or comma delimited email addresses and usernames are accepted." |
| 280 | | name="field_${field.name}" value="${value}" /> |
| | 280 | <input type="text" id="field-${field.name}" |
| | 281 | title="Space or comma delimited email addresses and usernames are accepted." |
| | 282 | name="field_${field.name}" value="${value}" /> |
| 281 | 283 | </span> |
| 282 | 284 | <!--! All the other text input fields --> |
| 283 | 285 | <input py:otherwise="" type="text" id="field-${field.name}" |
| 284 | | name="field_${field.name}" value="${value}" /> |
| | 286 | name="field_${field.name}" value="${value}" /> |
| 285 | 287 | </py:choose> |
| 286 | 288 | </py:otherwise> |
| 287 | 289 | </py:choose> |
diff --git a/trac/ticket/tests/api.py b/trac/ticket/tests/api.py
|
a
|
b
|
|
| 31 | 31 | self.env.config.set('ticket-custom', 'test.format', 'wiki') |
| 32 | 32 | fields = TicketSystem(self.env).get_custom_fields() |
| 33 | 33 | self.assertEqual({'name': 'test', 'type': 'text', 'label': 'Test', |
| 34 | | 'value': 'Foo bar', 'order': 0, 'format': 'wiki'}, |
| | 34 | 'value': 'Foo bar', 'order': 0, 'format': 'wiki', |
| | 35 | 'custom': True}, |
| 35 | 36 | fields[0]) |
| 36 | 37 | |
| 37 | 38 | def test_custom_field_select(self): |
| … |
… |
|
| 42 | 43 | fields = TicketSystem(self.env).get_custom_fields() |
| 43 | 44 | self.assertEqual({'name': 'test', 'type': 'select', 'label': 'Test', |
| 44 | 45 | 'value': '1', 'options': ['option1', 'option2'], |
| 45 | | 'order': 0}, |
| | 46 | 'order': 0, 'custom': True}, |
| 46 | 47 | fields[0]) |
| 47 | 48 | |
| 48 | 49 | def test_custom_field_optional_select(self): |
| … |
… |
|
| 53 | 54 | fields = TicketSystem(self.env).get_custom_fields() |
| 54 | 55 | self.assertEqual({'name': 'test', 'type': 'select', 'label': 'Test', |
| 55 | 56 | 'value': '1', 'options': ['option1', 'option2'], |
| 56 | | 'order': 0, 'optional': True}, |
| | 57 | 'order': 0, 'optional': True, 'custom': True}, |
| 57 | 58 | fields[0]) |
| 58 | 59 | |
| 59 | 60 | def test_custom_field_textarea(self): |
| … |
… |
|
| 66 | 67 | fields = TicketSystem(self.env).get_custom_fields() |
| 67 | 68 | self.assertEqual({'name': 'test', 'type': 'textarea', 'label': 'Test', |
| 68 | 69 | 'value': 'Foo bar', 'width': 60, 'height': 4, |
| 69 | | 'order': 0, 'format': 'wiki'}, |
| | 70 | 'order': 0, 'format': 'wiki', 'custom': True}, |
| 70 | 71 | fields[0]) |
| 71 | 72 | |
| 72 | 73 | def test_custom_field_order(self): |
diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
|
a
|
b
|
|
| 37 | 37 | from trac.ticket.notification import TicketNotifyEmail |
| 38 | 38 | from trac.timeline.api import ITimelineEventProvider |
| 39 | 39 | from trac.util import as_bool, as_int, get_reporter_id |
| 40 | | from trac.util.datefmt import format_datetime, from_utimestamp, \ |
| 41 | | to_utimestamp, utc |
| | 40 | from trac.util.datefmt import format_datetime, format_date, format_datetime, from_utimestamp, \ |
| | 41 | get_date_format_hint, get_datetime_format_hint, \ |
| | 42 | pretty_timedelta, parse_date, to_utimestamp, utc |
| 42 | 43 | from trac.util.text import exception_to_unicode, obfuscate_email_address, \ |
| 43 | 44 | shorten_line, to_unicode |
| 44 | 45 | from trac.util.presentation import separated |
| … |
… |
|
| 702 | 703 | for each in Ticket.protected_fields: |
| 703 | 704 | fields.pop(each, None) |
| 704 | 705 | fields.pop('checkbox_' + each, None) # See Ticket.populate() |
| | 706 | for field, value in fields.iteritems(): |
| | 707 | if field in ticket.time_fields: |
| | 708 | fields[field] = parse_date(value, req.tz, 'datetime') \ |
| | 709 | if value else None |
| 705 | 710 | ticket.populate(fields) |
| 706 | 711 | # special case for updating the Cc: field |
| 707 | 712 | if 'cc_update' in req.args: |
| … |
… |
|
| 1056 | 1061 | if name in ('cc', 'reporter'): |
| 1057 | 1062 | value = Chrome(self.env).format_emails(context, value, ' ') |
| 1058 | 1063 | elif name in ticket.time_fields: |
| 1059 | | value = format_datetime(value, '%Y-%m-%d %H:%M:%S', |
| 1060 | | tzinfo=req.tz) |
| | 1064 | format = ticket.fields.by_name(name).get('format') |
| | 1065 | value = self._render_time_field(req, value, format) |
| 1061 | 1066 | cols.append(value.encode('utf-8')) |
| 1062 | 1067 | writer.writerow(cols) |
| 1063 | 1068 | return (content.getvalue(), '%s;charset=utf-8' % mimetype) |
| … |
… |
|
| 1196 | 1201 | # Shouldn't happen in "normal" circumstances, hence not a warning |
| 1197 | 1202 | raise InvalidTicket(_("Invalid comment threading identifier")) |
| 1198 | 1203 | |
| | 1204 | # FIXME: Validate time field content |
| | 1205 | |
| 1199 | 1206 | # Custom validation rules |
| 1200 | 1207 | for manipulator in self.ticket_manipulators: |
| 1201 | 1208 | for field, message in manipulator.validate_ticket(req, ticket): |
| … |
… |
|
| 1362 | 1369 | type_ = field['type'] |
| 1363 | 1370 | |
| 1364 | 1371 | # enable a link to custom query for all choice fields |
| 1365 | | if type_ not in ['text', 'textarea']: |
| | 1372 | if type_ not in ['text', 'textarea', 'time']: |
| 1366 | 1373 | field['rendered'] = self._query_link(req, name, ticket[name]) |
| 1367 | 1374 | |
| 1368 | 1375 | # per field settings |
| … |
… |
|
| 1437 | 1444 | field['rendered'] = \ |
| 1438 | 1445 | format_to_html(self.env, context, ticket[name], |
| 1439 | 1446 | escape_newlines=self.must_preserve_newlines) |
| | 1447 | elif type_ == 'time': |
| | 1448 | value = ticket[name] |
| | 1449 | format = field.get('format', 'datetime') |
| | 1450 | field['rendered'] = self._render_time_field(req, value, format, |
| | 1451 | relative=True) |
| | 1452 | field['edit'] = self._render_time_field(req, value, format) |
| | 1453 | if format == 'date': |
| | 1454 | field['format_hint'] = get_date_format_hint() |
| | 1455 | else: |
| | 1456 | field['format_hint'] = get_datetime_format_hint() |
| 1440 | 1457 | |
| 1441 | 1458 | # ensure sane defaults |
| 1442 | 1459 | field.setdefault('optional', False) |
| … |
… |
|
| 1449 | 1466 | fields.remove(owner_field) |
| 1450 | 1467 | fields.append(owner_field) |
| 1451 | 1468 | return fields |
| 1452 | | |
| | 1469 | |
| 1453 | 1470 | def _insert_ticket_data(self, req, ticket, data, author_id, field_changes): |
| 1454 | 1471 | """Insert ticket data into the template `data`""" |
| 1455 | 1472 | replyto = req.args.get('replyto') |
| … |
… |
|
| 1602 | 1619 | resource_new) |
| 1603 | 1620 | if rendered: |
| 1604 | 1621 | changes['rendered'] = rendered |
| | 1622 | elif ticket.fields.by_name(field, {}).get('type') == 'time': |
| | 1623 | format = ticket.fields.by_name(field).get('format') |
| | 1624 | changes['old'] = self._render_time_field(req, old, format) |
| | 1625 | changes['new'] = self._render_time_field(req, new, format) |
| 1605 | 1626 | |
| 1606 | 1627 | def _render_property_diff(self, req, ticket, field, old, new, |
| 1607 | 1628 | resource_new=None): |
| 1608 | 1629 | rendered = None |
| 1609 | 1630 | # per type special rendering of diffs |
| 1610 | | type_ = None |
| 1611 | | for f in ticket.fields: |
| 1612 | | if f['name'] == field: |
| 1613 | | type_ = f['type'] |
| 1614 | | break |
| | 1631 | type_ = ticket.fields.by_name(field, {}).get('type') |
| 1615 | 1632 | if type_ == 'checkbox': |
| 1616 | 1633 | rendered = _("set") if new == '1' else _("unset") |
| 1617 | 1634 | elif type_ == 'textarea': |
| … |
… |
|
| 1662 | 1679 | old=tag.em(old), new=tag.em(new)) |
| 1663 | 1680 | return rendered |
| 1664 | 1681 | |
| | 1682 | def _render_time_field(self, req, value, format, relative=False): |
| | 1683 | format = format or 'datetime' |
| | 1684 | if format == 'age' and relative: |
| | 1685 | return pretty_timedelta(value) if value else '' |
| | 1686 | elif format == 'date': |
| | 1687 | return format_date(value, '%Y-%m-%d', tzinfo=req.tz) \ |
| | 1688 | if value else '' |
| | 1689 | else: |
| | 1690 | return format_datetime(value, '%Y-%m-%d %H:%M:%S', tzinfo=req.tz) \ |
| | 1691 | if value else '' |
| | 1692 | |
| 1665 | 1693 | def grouped_changelog_entries(self, ticket, db=None, when=None): |
| 1666 | 1694 | """Iterate on changelog entries, consolidating related changes |
| 1667 | 1695 | in a `dict` object. |
diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py
|
a
|
b
|
|
| 513 | 513 | t = tzinfo.localize(datetime(*(values[k] for k in 'yMdhms'))) |
| 514 | 514 | return tzinfo.normalize(t) |
| 515 | 515 | |
| 516 | | _REL_TIME_RE = re.compile( |
| 517 | | r'(\d+\.?\d*)\s*' |
| 518 | | r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*' |
| 519 | | r'(?:ago)?$') |
| | 516 | _REL_FUTURE_RE = re.compile( |
| | 517 | r'(?:in|\+)\s*(\d+\.?\d*)\s*' |
| | 518 | r'(second|minute|hour|day|week|month|year|[hdwmy])s?$') |
| | 519 | _REL_PAST_RE = re.compile( |
| | 520 | r'(?:-\s*)?(\d+\.?\d*)\s*' |
| | 521 | r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*(?:ago)?$') |
| 520 | 522 | _time_intervals = dict( |
| 521 | 523 | second=lambda v: timedelta(seconds=v), |
| 522 | 524 | minute=lambda v: timedelta(minutes=v), |
| … |
… |
|
| 531 | 533 | m=lambda v: timedelta(days=30 * v), |
| 532 | 534 | y=lambda v: timedelta(days=365 * v), |
| 533 | 535 | ) |
| 534 | | _TIME_START_RE = re.compile(r'(this|last)\s*' |
| | 536 | _TIME_START_RE = re.compile(r'(this|last|next)\s*' |
| 535 | 537 | r'(second|minute|hour|day|week|month|year)$') |
| 536 | 538 | _time_starts = dict( |
| 537 | 539 | second=lambda now: now.replace(microsecond=0), |
| … |
… |
|
| 543 | 545 | month=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0, |
| 544 | 546 | day=1), |
| 545 | 547 | year=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0, |
| 546 | | day=1, month=1), |
| | 548 | day=1, month=1), |
| 547 | 549 | ) |
| 548 | 550 | |
| 549 | 551 | def _parse_relative_time(text, tzinfo): |
| … |
… |
|
| 555 | 557 | if text == 'yesterday': |
| 556 | 558 | return now.replace(microsecond=0, second=0, minute=0, hour=0) \ |
| 557 | 559 | - timedelta(days=1) |
| 558 | | match = _REL_TIME_RE.match(text) |
| | 560 | if text == 'tomorrow': |
| | 561 | return now.replace(microsecond=0, second=0, minute=0, hour=0) \ |
| | 562 | + timedelta(days=1) |
| | 563 | match = _REL_FUTURE_RE.match(text) |
| 559 | 564 | if match: |
| 560 | | (value, interval) = match.groups() |
| | 565 | value, interval = match.groups() |
| | 566 | return now + _time_intervals[interval](float(value)) |
| | 567 | match = _REL_PAST_RE.match(text) |
| | 568 | if match: |
| | 569 | value, interval = match.groups() |
| 561 | 570 | return now - _time_intervals[interval](float(value)) |
| 562 | 571 | match = _TIME_START_RE.match(text) |
| 563 | 572 | if match: |
| 564 | | (which, start) = match.groups() |
| | 573 | which, start = match.groups() |
| 565 | 574 | dt = _time_starts[start](now) |
| 566 | 575 | if which == 'last': |
| 567 | 576 | if start == 'month': |
| … |
… |
|
| 571 | 580 | dt = dt.replace(year=dt.year - 1, month=12) |
| 572 | 581 | else: |
| 573 | 582 | dt -= _time_intervals[start](1) |
| | 583 | elif which == 'next': |
| | 584 | if start == 'month': |
| | 585 | if dt.month < 12: |
| | 586 | dt = dt.replace(month=dt.month + 1) |
| | 587 | else: |
| | 588 | dt = dt.replace(year=dt.year + 1, month=1) |
| | 589 | else: |
| | 590 | dt += _time_intervals[start](1) |
| 574 | 591 | return dt |
| 575 | | return None |
| 576 | 592 | |
| 577 | 593 | # -- formatting/parsing helper functions |
| 578 | 594 | |