Package trac :: Package ticket :: Module model

Source Code for Module trac.ticket.model

   1  # -*- coding: utf-8 -*- 
   2  # 
   3  # Copyright (C) 2003-2023 Edgewall Software 
   4  # Copyright (C) 2003-2006 Jonas Borgström <[email protected]> 
   5  # Copyright (C) 2005 Christopher Lenz <[email protected]> 
   6  # Copyright (C) 2006 Christian Boos <[email protected]> 
   7  # All rights reserved. 
   8  # 
   9  # This software is licensed as described in the file COPYING, which 
  10  # you should have received as part of this distribution. The terms 
  11  # are also available at https://trac.edgewall.org/wiki/TracLicense. 
  12  # 
  13  # This software consists of voluntary contributions made by many 
  14  # individuals. For the exact contribution history, see the revision 
  15  # history and logs, available at https://trac.edgewall.org/log/. 
  16  # 
  17  # Author: Jonas Borgström <[email protected]> 
  18  #         Christopher Lenz <[email protected]> 
  19   
  20  import re 
  21   
  22  from trac import core 
  23  from trac.attachment import Attachment 
  24  from trac.cache import cached 
  25  from trac.core import TracError 
  26  from trac.resource import Resource, ResourceExistsError, ResourceNotFound 
  27  from trac.ticket.api import TicketSystem 
  28  from trac.util import as_int, embedded_numbers, to_list 
  29  from trac.util.datefmt import (datetime_now, from_utimestamp, parse_date, 
  30                                 to_utimestamp, utc, utcmax) 
  31  from trac.util.text import empty, stripws 
  32  from trac.util.translation import _, N_, gettext 
  33   
  34  __all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity', 
  35             'Component', 'Milestone', 'Version'] 
36 37 38 -def _fixup_cc_list(cc_value):
39 """Fix up cc list separators and remove duplicates.""" 40 cclist = [] 41 for cc in to_list(cc_value, r'[;,\s]+'): 42 if cc not in cclist: 43 cclist.append(cc) 44 return ', '.join(cclist)
45
46 47 -def _db_str_to_datetime(value):
48 if value is None: 49 return None 50 try: 51 return from_utimestamp(int(value)) 52 except ValueError: 53 pass 54 try: 55 return parse_date(value.strip(), utc, 'datetime') 56 except Exception: 57 return None
58
59 60 -def _datetime_to_db_str(dt, is_custom_field):
61 if not dt: 62 return None 63 ts = to_utimestamp(dt) 64 if is_custom_field: 65 # Padding with '0' would be easy to sort in report page for a user 66 fmt = '%018d' if ts >= 0 else '%+017d' 67 return fmt % ts 68 else: 69 return ts
70
71 72 -def _from_timestamp(time):
73 return from_utimestamp(time) if time else None
74
75 76 -def _to_null(value):
77 return value if value else None
78
79 80 -def _null_to_empty(value):
81 return value if value else empty
82
83 84 -def simplify_whitespace(name):
85 """Strip spaces and remove duplicate spaces within names""" 86 if name: 87 return ' '.join(name.split()) 88 return name
89
90 91 -def sort_tickets_by_priority(env, ids):
92 with env.db_query as db: 93 tickets = [int(id_) for id_ in ids] 94 holders = ','.join(['%s'] * len(tickets)) 95 rows = db(""" 96 SELECT id FROM ticket AS t 97 LEFT OUTER JOIN enum p 98 ON p.type='priority' AND p.name=t.priority 99 WHERE t.id IN (%s) 100 ORDER BY COALESCE(p.value,'')='', %s, t.id 101 """ % (holders, db.cast('p.value', 'int')), tickets) 102 return [row[0] for row in rows]
103
104 105 -class Ticket(object):
106 107 realm = 'ticket' 108 109 # Fields that must not be modified directly by the user 110 # 'owner' should eventually be a protected field (#2045) 111 protected_fields = 'resolution', 'status', 'time', 'changetime' 112 113 @staticmethod
114 - def id_is_valid(num):
115 try: 116 return 0 < int(num) <= 1 << 31 117 except (ValueError, TypeError): 118 return False
119 120 @property
121 - def resource(self):
122 return Resource(self.realm, self.id, self.version)
123
124 - def __init__(self, env, tkt_id=None, version=None):
125 self.env = env 126 self.fields = TicketSystem(self.env).get_ticket_fields() 127 self.editable_fields = \ 128 {f['name'] for f in self.fields 129 if f['name'] not in self.protected_fields} 130 self.std_fields, self.custom_fields, self.time_fields = [], [], [] 131 for f in self.fields: 132 if f.get('custom'): 133 self.custom_fields.append(f['name']) 134 else: 135 self.std_fields.append(f['name']) 136 if f['type'] == 'time': 137 self.time_fields.append(f['name']) 138 self.values = {} 139 self._old = {} 140 if tkt_id is not None: 141 self._fetch_ticket(tkt_id) 142 else: 143 self._init_defaults() 144 self.id = None 145 self.version = version
146
147 - def __repr__(self):
148 return '<%s %r>' % (self.__class__.__name__, self.id)
149 150 exists = property(lambda self: self.id is not None) 151
152 - def _init_defaults(self):
153 for field in self.fields: 154 default = None 155 if field['name'] in self.protected_fields: 156 # Ignore for new - only change through workflow 157 pass 158 elif not field.get('custom'): 159 default = self.env.config.get('ticket', 160 'default_' + field['name']) 161 else: 162 default = self._custom_field_default(field) 163 if default: 164 self.values.setdefault(field['name'], default)
165
166 - def _custom_field_default(self, field):
167 default = field.get('value') 168 options = field.get('options') 169 if default and options and default not in options: 170 try: 171 default = options[int(default)] 172 except (ValueError, IndexError): 173 self.env.log.warning("Invalid default value '%s' " 174 "for custom field '%s'", 175 default, field['name']) 176 if default and field.get('type') == 'time': 177 try: 178 default = parse_date(default, 179 hint=field.get('format')) 180 except TracError as e: 181 self.env.log.warning("Invalid default value '%s' " 182 "for custom field '%s': %s", 183 default, field['name'], e) 184 default = None 185 return default
186
187 - def _fetch_ticket(self, tkt_id):
188 row = None 189 if self.id_is_valid(tkt_id): 190 # Fetch the standard ticket fields 191 tkt_id = int(tkt_id) 192 for row in self.env.db_query(""" 193 SELECT %s FROM ticket WHERE id=%%s 194 """ % ','.join(self.std_fields), (tkt_id,)): 195 break 196 if not row: 197 raise ResourceNotFound(_("Ticket %(id)s does not exist.", 198 id=tkt_id), _("Invalid ticket number")) 199 200 self.id = tkt_id 201 for i, field in enumerate(self.std_fields): 202 value = row[i] 203 if field in self.time_fields: 204 self.values[field] = from_utimestamp(value) 205 elif value is None: 206 self.values[field] = empty 207 else: 208 self.values[field] = value 209 210 # Fetch custom fields if available 211 for name, value in self.env.db_query(""" 212 SELECT name, value FROM ticket_custom WHERE ticket=%s 213 """, (tkt_id,)): 214 if name in self.custom_fields: 215 if name in self.time_fields: 216 self.values[name] = _db_str_to_datetime(value) 217 elif value is None: 218 self.values[name] = empty 219 else: 220 self.values[name] = value 221 222 # Set defaults for custom fields that haven't been fetched. 223 for field in self.fields: 224 name = field['name'] 225 if field.get('custom') and name not in self.values: 226 default = self._custom_field_default(field) 227 if default: 228 self[name] = default
229
230 - def __getitem__(self, name):
231 return self.values.get(name)
232
233 - def __setitem__(self, name, value):
234 """Log ticket modifications so the table ticket_change can be updated 235 """ 236 if value and name not in self.time_fields: 237 if isinstance(value, list): 238 raise TracError(_("Multi-values fields not supported yet")) 239 if self.fields.by_name(name, {}).get('type') != 'textarea': 240 value = value.strip() 241 if name in self.values and self.values[name] == value: 242 return 243 if name not in self._old: # Changed field 244 self._old[name] = self.values.get(name) 245 elif self._old[name] == value: # Change of field reverted 246 del self._old[name] 247 self.values[name] = value
248
249 - def __contains__(self, item):
250 return item in self.values
251
252 - def get_value_or_default(self, name):
253 """Return the value of a field or the default value if it is undefined 254 """ 255 try: 256 value = self.values[name] 257 return value if value is not empty else self.get_default(name) 258 except KeyError: 259 pass
260
261 - def get_default(self, name):
262 """Return the default value of a field.""" 263 return self.fields.by_name(name, {}).get('value', '')
264
265 - def populate(self, values):
266 """Populate the ticket with 'suitable' values from a dictionary""" 267 field_names = [f['name'] for f in self.fields] 268 for name in [name for name in values if name in field_names]: 269 self[name] = values[name] 270 271 # We have to do an extra trick to catch unchecked checkboxes 272 for name in [name for name in values if name[9:] in field_names 273 and name.startswith('checkbox_')]: 274 if name[9:] not in values: 275 self[name[9:]] = '0'
276
277 - def insert(self, when=None):
278 """Add ticket to database. 279 """ 280 assert not self.exists, 'Cannot insert an existing ticket' 281 282 if 'cc' in self.values: 283 self['cc'] = _fixup_cc_list(self.values['cc']) 284 285 # Add a timestamp 286 if when is None: 287 when = datetime_now(utc) 288 self.values['time'] = self.values['changetime'] = when 289 290 # Perform type conversions 291 db_values = self._to_db_types(self.values) 292 293 # Insert ticket record 294 std_fields = [] 295 custom_fields = [] 296 for f in self.fields: 297 fname = f['name'] 298 if fname in self.values: 299 if f.get('custom'): 300 custom_fields.append(fname) 301 else: 302 std_fields.append(fname) 303 with self.env.db_transaction as db: 304 cursor = db.cursor() 305 cursor.execute("INSERT INTO ticket (%s) VALUES (%s)" 306 % (','.join(std_fields), 307 ','.join(['%s'] * len(std_fields))), 308 [db_values.get(name) for name in std_fields]) 309 tkt_id = db.get_last_id(cursor, 'ticket') 310 311 # Insert custom fields 312 if custom_fields: 313 db.executemany( 314 """INSERT INTO ticket_custom (ticket, name, value) 315 VALUES (%s, %s, %s) 316 """, [(tkt_id, c, db_values.get(c)) 317 for c in custom_fields]) 318 319 self.id = int(tkt_id) 320 self._old = {} 321 322 for listener in TicketSystem(self.env).change_listeners: 323 listener.ticket_created(self) 324 325 return self.id
326
327 - def get_comment_number(self, cdate):
328 """Return a comment number by its date.""" 329 ts = to_utimestamp(cdate) 330 for cnum, in self.env.db_query("""\ 331 SELECT oldvalue FROM ticket_change 332 WHERE ticket=%s AND time=%s AND field='comment' 333 """, (self.id, ts)): 334 try: 335 return int(cnum.rsplit('.', 1)[-1]) 336 except ValueError: 337 break
338
339 - def save_changes(self, author=None, comment=None, when=None, replyto=None):
340 """ 341 Store ticket changes in the database. The ticket must already exist in 342 the database. Returns False if there were no changes to save, True 343 otherwise. 344 """ 345 assert self.exists, "Cannot update a new ticket" 346 347 if 'cc' in self.values: 348 self['cc'] = _fixup_cc_list(self.values['cc']) 349 350 props_unchanged = all(self.values.get(k) == v 351 for k, v in self._old.iteritems()) 352 if (not comment or not stripws(comment)) and props_unchanged: 353 return False # Not modified 354 355 if when is None: 356 when = datetime_now(utc) 357 self.values['changetime'] = when 358 359 # Perform type conversions 360 db_values = self._to_db_types(self.values) 361 old_db_values = self._to_db_types(self._old) 362 363 with self.env.db_transaction as db: 364 db("UPDATE ticket SET changetime=%s WHERE id=%s", 365 (db_values['changetime'], self.id)) 366 367 num = 0 368 for ts, old in db(""" 369 SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,'') 370 FROM ticket_change AS tc1 371 LEFT OUTER JOIN ticket_change AS tc2 372 ON tc2.ticket=%s AND tc2.time=tc1.time 373 AND tc2.field='comment' 374 WHERE tc1.ticket=%s ORDER BY tc1.time DESC 375 """, (self.id, self.id)): 376 # Use oldvalue if available, else count edits 377 try: 378 num += int(old.rsplit('.', 1)[-1]) 379 break 380 except ValueError: 381 num += 1 382 cnum = str(num + 1) 383 if replyto: 384 cnum = '%s.%s' % (replyto, cnum) 385 386 # store fields 387 for name in self._old: 388 db_val = db_values.get(name) 389 old_db_val = old_db_values.get(name) 390 if name in self.custom_fields: 391 for row in db("""SELECT * FROM ticket_custom 392 WHERE ticket=%s and name=%s 393 """, (self.id, name)): 394 db("""UPDATE ticket_custom SET value=%s 395 WHERE ticket=%s AND name=%s 396 """, (db_val, self.id, name)) 397 break 398 else: 399 db("""INSERT INTO ticket_custom (ticket,name,value) 400 VALUES(%s,%s,%s) 401 """, (self.id, name, db_val)) 402 # Don't add ticket change entry for custom field that 403 # was added after ticket was created. 404 if old_db_val is None: 405 field = self.fields.by_name(name) 406 default = self._custom_field_default(field) 407 if self.values.get(name) == default: 408 continue 409 else: 410 db("UPDATE ticket SET %s=%%s WHERE id=%%s" 411 % name, (db_val, self.id)) 412 db("""INSERT INTO ticket_change 413 (ticket,time,author,field,oldvalue,newvalue) 414 VALUES (%s, %s, %s, %s, %s, %s) 415 """, (self.id, db_values['changetime'], author, name, 416 old_db_val, db_val)) 417 418 # always save comment, even if empty 419 # (numbering support for timeline) 420 db("""INSERT INTO ticket_change 421 (ticket,time,author,field,oldvalue,newvalue) 422 VALUES (%s,%s,%s,'comment',%s,%s) 423 """, (self.id, db_values['changetime'], author, cnum, 424 comment)) 425 426 old_values = self._old 427 self._old = {} 428 429 for listener in TicketSystem(self.env).change_listeners: 430 listener.ticket_changed(self, comment, author, old_values) 431 return int(cnum.rsplit('.', 1)[-1])
432
433 - def _to_db_types(self, values):
434 values = values.copy() 435 for field, value in values.iteritems(): 436 if field in self.time_fields: 437 is_custom_field = field in self.custom_fields 438 values[field] = _datetime_to_db_str(value, is_custom_field) 439 else: 440 values[field] = _to_null(value) 441 return values
442
443 - def get_changelog(self, when=None):
444 """Return the changelog as a list of tuples of the form 445 (time, author, field, oldvalue, newvalue, permanent). 446 447 While the other tuple elements are quite self-explanatory, 448 the `permanent` flag is used to distinguish collateral changes 449 that are not yet immutable (like attachments, currently). 450 """ 451 sid = str(self.id) 452 when_ts = to_utimestamp(when) 453 if when_ts: 454 sql = """ 455 SELECT time, author, field, oldvalue, newvalue, 1 AS permanent 456 FROM ticket_change WHERE ticket=%s AND time=%s 457 UNION 458 SELECT time, author, 'attachment', null, filename, 459 0 AS permanent 460 FROM attachment WHERE type='ticket' AND id=%s AND time=%s 461 UNION 462 SELECT time, author, 'comment', null, description, 463 0 AS permanent 464 FROM attachment WHERE type='ticket' AND id=%s AND time=%s 465 ORDER BY time,permanent,author,field 466 """ 467 args = (self.id, when_ts, sid, when_ts, sid, when_ts) 468 else: 469 sql = """ 470 SELECT time, author, field, oldvalue, newvalue, 1 AS permanent 471 FROM ticket_change WHERE ticket=%s 472 UNION 473 SELECT time, author, 'attachment', null, filename, 474 0 AS permanent 475 FROM attachment WHERE type='ticket' AND id=%s 476 UNION 477 SELECT time, author, 'comment', null, description, 478 0 AS permanent 479 FROM attachment WHERE type='ticket' AND id=%s 480 ORDER BY time,permanent,author,field 481 """ 482 args = (self.id, sid, sid) 483 log = [] 484 for t, author, field, oldvalue, newvalue, permanent \ 485 in self.env.db_query(sql, args): 486 if field in self.time_fields: 487 oldvalue = _db_str_to_datetime(oldvalue) 488 newvalue = _db_str_to_datetime(newvalue) 489 log.append((from_utimestamp(t), author, field, 490 oldvalue or '', newvalue or '', permanent)) 491 return log
492
493 - def delete(self):
494 """Delete the ticket. 495 """ 496 with self.env.db_transaction as db: 497 Attachment.delete_all(self.env, self.realm, self.id) 498 db("DELETE FROM ticket WHERE id=%s", (self.id,)) 499 db("DELETE FROM ticket_change WHERE ticket=%s", (self.id,)) 500 db("DELETE FROM ticket_custom WHERE ticket=%s", (self.id,)) 501 502 for listener in TicketSystem(self.env).change_listeners: 503 listener.ticket_deleted(self)
504
505 - def get_change(self, cnum=None, cdate=None):
506 """Return a ticket change by its number or date. 507 """ 508 if cdate is None: 509 row = self._find_change(cnum) 510 if not row: 511 return 512 cdate = from_utimestamp(row[0])