Edgewall Software

source: trunk/trac/ticket/model.py

Last change on this file was 17657, checked in by Jun Omae, 16 months ago

1.5.4dev: update copyright year to 2023 (refs #13402)

[skip ci]

  • Property svn:eol-style set to native
File size: 56.3 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2023 Edgewall Software
4# Copyright (C) 2003-2006 Jonas Borgström <jonas@edgewall.com>
5# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
6# Copyright (C) 2006 Christian Boos <cboos@edgewall.org>
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 <jonas@edgewall.com>
18# Christopher Lenz <cmlenz@gmx.de>
19
20import re
21
22from trac import core
23from trac.attachment import Attachment
24from trac.cache import cached
25from trac.core import TracError
26from trac.resource import Resource, ResourceExistsError, ResourceNotFound
27from trac.ticket.api import TicketSystem
28from trac.util import as_int, embedded_numbers, to_list
29from trac.util.datefmt import (datetime_now, from_utimestamp, parse_date,
30 to_utimestamp, utc, utcmax)
31from trac.util.text import empty, stripws
32from trac.util.translation import _, N_, gettext
33
34__all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity',
35 'Component', 'Milestone', 'Version']
36
37
38def _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
47def _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
60def _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
72def _from_timestamp(time):
73 return from_utimestamp(time) if time else None
74
75
76def _to_null(value):
77 return value if value else None
78
79
80def _null_to_empty(value):
81 return value if value else empty
82
83
84def 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
91def 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
105class 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.items())
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.items():
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])
513 ts = to_utimestamp(cdate)
514 fields = {}
515 change = {'date': cdate, 'fields': fields}
516 for field, author, old, new in self.env.db_query("""
517 SELECT field, author, oldvalue, newvalue
518 FROM ticket_change WHERE ticket=%s AND time=%s
519 """, (self.id, ts)):
520 fields[field] = {'author': author, 'old': old, 'new': new}
521 if field == 'comment':
522 change['author'] = author
523 elif not field.startswith('_'):
524 change.setdefault('author', author)
525 if fields:
526 return change
527
528 def delete_change(self, cnum=None, cdate=None, when=None):
529 """Delete a ticket change identified by its number or date."""
530 if cdate is None:
531 row = self._find_change(cnum)
532 if not row:
533 return
534 cdate = from_utimestamp(row[0])
535 ts = to_utimestamp(cdate)
536 if when is None:
537 when = datetime_now(utc)
538 when_ts = to_utimestamp(when)
539
540 with self.env.db_transaction as db:
541 # Find modified fields and their previous value
542 fields = [(field, old, new)
543 for field, old, new in db("""
544 SELECT field, oldvalue, newvalue FROM ticket_change
545 WHERE ticket=%s AND time=%s
546 """, (self.id, ts))
547 if field != 'comment' and not field.startswith('_')]
548 for field, oldvalue, newvalue in fields:
549 # Find the next change
550 for next_ts, in db("""SELECT time FROM ticket_change
551 WHERE ticket=%s AND time>%s AND field=%s
552 LIMIT 1
553 """, (self.id, ts, field)):
554 # Modify the old value of the next change if it is equal
555 # to the new value of the deleted change
556 db("""UPDATE ticket_change SET oldvalue=%s
557 WHERE ticket=%s AND time=%s AND field=%s
558 AND oldvalue=%s
559 """, (oldvalue, self.id, next_ts, field, newvalue))
560 break
561 else:
562 # No next change, edit ticket field
563 if field in self.std_fields:
564 db("UPDATE ticket SET %s=%%s WHERE id=%%s"
565 % field, (oldvalue, self.id))
566 else:
567 db("""UPDATE ticket_custom SET value=%s
568 WHERE ticket=%s AND name=%s
569 """, (oldvalue, self.id, field))
570
571 # Delete the change
572 db("DELETE FROM ticket_change WHERE ticket=%s AND time=%s",
573 (self.id, ts))
574
575 # Update last changed time
576 db("UPDATE ticket SET changetime=%s WHERE id=%s",
577 (when_ts, self.id))
578
579 self._fetch_ticket(self.id)
580
581 changes = {field: (oldvalue, newvalue)
582 for field, oldvalue, newvalue in fields}
583 for listener in TicketSystem(self.env).change_listeners:
584 if hasattr(listener, 'ticket_change_deleted'):
585 listener.ticket_change_deleted(self, cdate, changes)
586
587 def modify_comment(self, cdate, author, comment, when=None):
588 """Modify a ticket comment specified by its date, while keeping a
589 history of edits.
590 """
591 ts = to_utimestamp(cdate)
592 if when is None:
593 when = datetime_now(utc)
594 when_ts = to_utimestamp(when)
595
596 old_comment = False
597 with self.env.db_transaction as db:
598 # Find the current value of the comment
599 for old_comment, in db("""
600 SELECT newvalue FROM ticket_change
601 WHERE ticket=%s AND time=%s AND field='comment'
602 """, (self.id, ts)):
603 break
604 if comment == (old_comment or ''):
605 return
606
607 # Comment history is stored in fields named "_comment%d"
608 # Find the next edit number
609 fields = db("""SELECT field FROM ticket_change
610 WHERE ticket=%%s AND time=%%s AND field %s
611 """ % db.prefix_match(),
612 (self.id, ts, db.prefix_match_value('_comment')))
613 rev = max(int(field[8:]) for field, in fields) + 1 \
614 if fields else 0
615 db("""INSERT INTO ticket_change
616 (ticket,time,author,field,oldvalue,newvalue)
617 VALUES (%s,%s,%s,%s,%s,%s)
618 """, (self.id, ts, author, '_comment%d' % rev,
619 old_comment or '', str(when_ts)))
620 if old_comment is False:
621 # There was no comment field, add one, find the
622 # original author in one of the other changed fields
623 for old_author, in db("""
624 SELECT author FROM ticket_change
625 WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1
626 """ % db.prefix_match(),
627 (self.id, ts, db.prefix_match_value('_'))):
628 db("""INSERT INTO ticket_change
629 (ticket,time,author,field,oldvalue,newvalue)
630 VALUES (%s,%s,%s,'comment','',%s)
631 """, (self.id, ts, old_author, comment))
632 else:
633 db("""UPDATE ticket_change SET newvalue=%s
634 WHERE ticket=%s AND time=%s AND field='comment'
635 """, (comment, self.id, ts))
636
637 # Update last changed time
638 db("UPDATE ticket SET changetime=%s WHERE id=%s",
639 (when_ts, self.id))
640
641 self.values['changetime'] = when
642
643 old_comment = old_comment or ''
644 for listener in TicketSystem(self.env).change_listeners:
645 if hasattr(listener, 'ticket_comment_modified'):
646 listener.ticket_comment_modified(self, cdate, author, comment,
647 old_comment)
648
649 def get_comment_history(self, cnum=None, cdate=None):
650 """Retrieve the edit history of a comment identified by its number or
651 date.
652 """
653 if cdate is None:
654 row = self._find_change(cnum)
655 if not row:
656 return
657 ts0, author0, last_comment = row
658 else:
659 ts0, author0, last_comment = to_utimestamp(cdate), None, None
660 with self.env.db_query as db:
661 # Get last comment and author if not available
662 if last_comment is None:
663 last_comment = ''
664 for author0, last_comment in db("""
665 SELECT author, newvalue FROM ticket_change
666 WHERE ticket=%s AND time=%s AND field='comment'
667 """, (self.id, ts0)):
668 break
669 if author0 is None:
670 for author0, last_comment in db("""
671 SELECT author, newvalue FROM ticket_change
672 WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1
673 """ % db.prefix_match(),
674 (self.id, ts0, db.prefix_match_value('_'))):
675 break
676 else:
677 return
678
679 # Get all fields of the form "_comment%d"
680 rows = db("""SELECT field, author, oldvalue, newvalue
681 FROM ticket_change
682 WHERE ticket=%%s AND time=%%s AND field %s
683 """ % db.prefix_match(),
684 (self.id, ts0, db.prefix_match_value('_comment')))
685 rows = sorted((int(field[8:]), author, old, new)
686 for field, author, old, new in rows)
687 history = []
688 for rev, author, comment, ts in rows:
689 history.append((rev, from_utimestamp(int(ts0)), author0,
690 comment))
691 ts0, author0 = ts, author
692 history.sort()
693 rev = history[-1][0] + 1 if history else 0
694 history.append((rev, from_utimestamp(int(ts0)), author0,
695 last_comment))
696 return history
697
698 def _find_change(self, cnum):
699 """Find a comment by its number."""
700 scnum = str(cnum)
701 with self.env.db_query as db:
702 for row in db("""
703 SELECT time, author, newvalue FROM ticket_change
704 WHERE ticket=%%s AND field='comment'
705 AND (oldvalue=%%s OR oldvalue %s)
706 """ % db.like(),
707 (self.id, scnum, '%' + db.like_escape('.' + scnum))):
708 return row
709
710 # Fallback when comment number is not available in oldvalue
711 num = 0
712 for ts, old, author, comment in db("""
713 SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,''),
714 tc2.author, COALESCE(tc2.newvalue,'')
715 FROM ticket_change AS tc1
716 LEFT OUTER JOIN ticket_change AS tc2
717 ON tc2.ticket=%s AND tc2.time=tc1.time
718 AND tc2.field='comment'
719 WHERE tc1.ticket=%s ORDER BY tc1.time
720 """, (self.id, self.id)):
721 # Use oldvalue if available, else count edits
722 try:
723 num = int(old.rsplit('.', 1)[-1])
724 except ValueError:
725 num += 1
726 if num == cnum:
727 break
728 else:
729 return
730
731 # Find author if NULL
732 if author is None:
733 for author, in db("""
734 SELECT author FROM ticket_change
735 WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1
736 """ % db.prefix_match(),
737 (self.id, ts, db.prefix_match_value('_'))):
738 break
739 return ts, author, comment
740
741
742class AbstractEnum(object):
743 type = None
744 ticket_col = None
745 label = None
746
747 exists = property(lambda self: self._old_value is not None)
748
749 def __init__(self, env, name=None):
750 """Create a new `enum` instance. If `name` is specified and the
751 enum with `name` exists, the enum will be retrieved from the
752 database.
753
754 :raises ResourceNotFound: if `name` is not `None` and an enum
755 with `name` does not exist.
756 """
757 if not self.ticket_col:
758 self.ticket_col = self.type
759 self.env = env
760 self.name = self._old_name = None
761 self.value = self._old_value = None
762 self.description = None
763 if name:
764 for value, description in self.env.db_query("""
765 SELECT value, description FROM enum
766 WHERE type=%s AND name=%s
767 """, (self.type, name)):
768 self.value = self._old_value = value
769 self.description = _null_to_empty(description)
770 self.name = self._old_name = name
771 break
772 else:
773 raise ResourceNotFound(_("%(type)s %(name)s does not exist.",
774 type=gettext(self.label[0]),
775 name=name))
776
777 def __repr__(self):
778 return '<%s %r %r>' % (self.__class__.__name__, self.name, self.value)
779
780 def delete(self):
781 """Delete the enum.
782
783 :raises TracError: if enum does not exist.
784 """
785 if not self.exists:
786 raise TracError(_("Cannot delete non-existent %(type)s.",
787 type=gettext(self.label[0])))
788
789 self.env.log.info("Deleting %s '%s'", self.type, self.name)
790 with self.env.db_transaction as db:
791 db("DELETE FROM enum WHERE type=%s AND value=%s",
792 (self.type, self._old_value))
793 # Re-order any enums that have higher value than deleted
794 # (close gap)
795 for enum in self.select(self.env):
796 try:
797 if int(enum.value) > int(self._old_value):
798 enum.value = str(int(enum.value) - 1)
799 enum.update()
800 except ValueError:
801 pass # Ignore cast error for this non-essential operation
802 TicketSystem(self.env).reset_ticket_fields()
803 self.value = self._old_value = None
804 self.name = self._old_name = None
805 self.description = None
806
807 def insert(self):
808 """Add a new enum.
809
810 :raises TracError: if enum name is empty.
811 :raises ResourceExistsError: if enum with name already exists.
812 """
813 if self.exists:
814 raise ResourceExistsError(
815 _('%(type)s value "%(name)s" already exists.',
816 type=gettext(self.label[0]), name=self.name))
817 self._check_and_coerce_fields()
818
819 self.env.log.debug("Creating new %s '%s'", self.type, self.name)
820 with self.env.db_transaction as db:
821 try:
822 if not self.value:
823 row = db("""SELECT COALESCE(MAX(%s), 0) FROM enum
824 WHERE type=%%s
825 """ % db.cast('value', 'int'), (self.type,))
826 self.value = int(float(row[0][0])) + 1 if row else 0
827 db("""
828 INSERT INTO enum (type, name, value, description)
829 VALUES (%s, %s, %s, %s)
830 """, (self.type, self.name, self.value,
831 _to_null(self.description)))
832 except self.env.db_exc.IntegrityError:
833 raise ResourceExistsError(
834 _('%(type)s value "%(name)s" already exists',
835 type=gettext(self.label[0]), name=self.name))
836 TicketSystem(self.env).reset_ticket_fields()
837 self._old_name = self.name
838 self._old_value = self.value
839
840 def update(self):
841 """Update the enum.
842
843 :raises TracError: if enum does not exist or enum name is empty.
844 :raises ResourceExistsError: if renamed enum already exists.
845 """
846 if not self.exists:
847 raise TracError(_("Cannot update non-existent enum."))
848 self._check_and_coerce_fields()
849
850 self.env.log.info("Updating %s '%s'", self.type, self.name)
851 with self.env.db_transaction as db:
852 try:
853 db("""UPDATE enum SET name=%s,value=%s,description=%s
854 WHERE type=%s AND name=%s
855 """, (self.name, self.value,
856 _to_null(self.description), self.type,
857 self._old_name))
858 except self.env.db_exc.IntegrityError:
859 raise ResourceExistsError(
860 _('%(type)s value "%(name)s" already exists',
861 type=gettext(self.label[0]), name=self.name))
862 self._old_value = self.value
863 if self.name != self._old_name:
864 # Update tickets
865 db("UPDATE ticket SET %s=%%s WHERE %s=%%s"
866 % (self.ticket_col, self.ticket_col),
867 (self.name, self._old_name))
868 self._old_name = self.name
869 TicketSystem(self.env).reset_ticket_fields()
870
871 @classmethod
872 def select(cls, env):
873 with env.db_query as db:
874 for name, value, description in db("""
875 SELECT name, value, description FROM enum
876 WHERE type=%s ORDER BY
877 """ + db.cast('value', 'int'),
878 (cls.type,)):
879 obj = cls(env)
880 obj.name = obj._old_name = name
881 obj.value = obj._old_value = value
882 obj.description = _null_to_empty(description)
883 yield obj
884
885 def _check_and_coerce_fields(self):
886 self.name = simplify_whitespace(self.name)
887 if not self.name:
888 raise TracError(_("Invalid %(type)s name.",
889 type=gettext(self.label[0])))
890
891
892class Type(AbstractEnum):
893 type = 'ticket_type'
894 ticket_col = 'type'
895 label = N_("Ticket Type"), N_("Ticket Types")
896
897
898class Status(object):
899 def __init__(self, env):
900 self.env = env
901
902 @classmethod
903 def select(cls, env):
904 for state in TicketSystem(env).get_all_status():
905 status = cls(env)
906 status.name = state
907 yield status
908
909 def __repr__(self):
910 return '<%s %r>' % (self.__class__.__name__, self.name)
911
912
913class Resolution(AbstractEnum):
914 type = 'resolution'
915 label = N_("Resolution"), N_("Resolutions")
916
917
918class Priority(AbstractEnum):
919 type = 'priority'
920 label = N_("Priority"), N_("Priorities")
921
922
923class Severity(AbstractEnum):
924 type = 'severity'
925 label = N_("Severity"), N_("Severities")
926
927
928class Component(object):
929
930 realm = 'component'
931
932 exists = property(lambda self: self._old_name is not None)
933
934 @property
935 def resource(self):
936 return Resource(self.realm, self.name)
937
938 def __init__(self, env, name=None):
939 """Create a new `Component` instance. If `name` is specified
940 and the component with `name` exists, the component will be
941 retrieved from the database.
942
943 :raises ResourceNotFound: if `name` is not `None` and component
944 with `name` does not exist.
945 """
946 self.env = env
947 self.name = self._old_name = self.owner = self.description = None
948 if name:
949 for owner, description in self.env.db_query("""
950 SELECT owner, description FROM component WHERE name=%s
951 """, (name,)):
952 self.name = self._old_name = name
953 self.owner = _null_to_empty(owner)
954 self.description = _null_to_empty(description)
955 break
956 else:
957 raise ResourceNotFound(_("Component %(name)s does not exist.",
958 name=name))
959
960 def __repr__(self):
961 return '<%s %r>' % (self.__class__.__name__, self.name)
962
963 def delete(self):
964 """Delete the component.
965
966 :raises TracError: if component does not exist.
967 """
968 if not self.exists:
969 raise TracError(_("Cannot delete non-existent component."))
970
971 self.env.log.info("Deleting component '%s'", self.name)
972 with self.env.db_transaction as db:
973 db("DELETE FROM component WHERE name=%s", (self.name,))
974 TicketSystem(self.env).reset_ticket_fields()
975 self.name = self._old_name = None
976
977 def insert(self):
978 """Insert a new component.
979
980 :raises TracError: if component name is empty.
981 :raises ResourceExistsError: if component with name already exists.
982 """
983 if self.exists:
984 raise ResourceExistsError(
985 _('Component "%(name)s" already exists.', name=self.name))
986 self._check_and_coerce_fields()
987
988 self.env.log.debug("Creating new component '%s'", self.name)
989 with self.env.db_transaction as db:
990 try:
991 db("""
992 INSERT INTO component (name,owner,description)
993 VALUES (%s,%s,%s)
994 """, (self.name, _to_null(self.owner),
995 _to_null(self.description)))
996 except self.env.db_exc.IntegrityError:
997 raise ResourceExistsError(
998 _('Component "%(name)s" already exists.', name=self.name))
999 TicketSystem(self.env).reset_ticket_fields()
1000 self._old_name = self.name
1001
1002 def update(self):
1003 """Update the component.
1004
1005 :raises TracError: if component does not exist or component name
1006 is empty.
1007 :raises ResourceExistsError: if renamed component already exists.
1008 """
1009 if not self.exists:
1010 raise TracError(_("Cannot update non-existent component."))
1011 self._check_and_coerce_fields()
1012
1013 self.env.log.info("Updating component '%s'", self.name)
1014 with self.env.db_transaction as db:
1015 try:
1016 db("""UPDATE component SET name=%s,owner=%s, description=%s
1017 WHERE name=%s
1018 """, (self.name, _to_null(self.owner),
1019 _to_null(self.description), self._old_name))
1020 except self.env.db_exc.IntegrityError:
1021 raise ResourceExistsError(
1022 _('Component "%(name)s" already exists.', name=self.name))
1023 if self.name != self._old_name:
1024 # Update tickets
1025 db("UPDATE ticket SET component=%s WHERE component=%s",
1026 (self.name, self._old_name))
1027 self._old_name = self.name
1028 TicketSystem(self.env).reset_ticket_fields()
1029
1030 @classmethod
1031 def select(cls, env):
1032 for name, owner, description in env.db_query("""
1033 SELECT name, owner, description FROM component ORDER BY name
1034 """):
1035 component = cls(env)
1036 component.name = component._old_name = name
1037 component.owner = _null_to_empty(owner)
1038 component.description = _null_to_empty(description)
1039 yield component
1040
1041 def _check_and_coerce_fields(self):
1042 self.name = simplify_whitespace(self.name)
1043 self.owner = simplify_whitespace(self.owner)
1044 if not self.name:
1045 raise TracError(_("Invalid component name."))
1046
1047
1048class MilestoneCache(core.Component):
1049 """Cache for milestone data and factory for 'milestone' resources."""
1050
1051 @cached
1052 def milestones(self):
1053 """Dictionary containing milestone data, indexed by name.
1054
1055 Milestone data consist of a tuple containing the name, the
1056 datetime objects for due and completed dates and the
1057 description.
1058 """
1059 milestones = {}
1060 for name, due, completed, description in self.env.db_query("""
1061 SELECT name, due, completed, description FROM milestone
1062 """):
1063 milestones[name] = (
1064 name,
1065 _from_timestamp(due),
1066 _from_timestamp(completed),
1067 description or '')
1068 return milestones
1069
1070 def fetchone(self, name, milestone=None):
1071 """Retrieve an existing milestone having the given `name`.
1072
1073 If `milestone` is specified, fill that instance instead of creating
1074 a fresh one.
1075
1076 :return: `None` if no such milestone exists
1077 """
1078 data = self.milestones.get(name)
1079 if data:
1080 return self.factory(data, milestone)
1081
1082 def fetchall(self):
1083 """Iterator on all milestones."""
1084 for data in self.milestones.values():
1085 yield self.factory(data)
1086
1087 def factory(self, values, milestone=None):
1088 """Build a `Milestone` object from milestone data.
1089
1090 That instance remains *private*, i.e. can't be retrieved by
1091 name by other processes or even by other threads in the same
1092 process, until its `~Milestone.insert` method gets called with
1093 success.
1094 """
1095 name, due, completed, description = values
1096 milestone = milestone or Milestone(self.env)
1097 milestone.name = name
1098 milestone.due = due
1099 milestone.completed = completed
1100 milestone.description = description
1101 milestone.checkin(invalidate=False)
1102 return milestone
1103
1104
1105class Milestone(object):
1106
1107 realm = 'milestone'
1108
1109 @property
1110 def resource(self):
1111 return Resource(self.realm, self.name) ### .version !!!
1112
1113 def __init__(self, env, name=None):
1114 """Create an undefined milestone or fetch one from the database,
1115 if `name` is given.
1116
1117 In the latter case however, raise `~trac.resource.ResourceNotFound`
1118 if a milestone of that name doesn't exist yet.
1119 """
1120 self.env = env
1121 self.name = self._old = None
1122 if name:
1123 if not self.cache.fetchone(name, self):
1124 raise ResourceNotFound(
1125 _("Milestone %(name)s does not exist.",
1126 name=name), _("Invalid milestone name."))
1127 else:
1128 self.cache.factory((None, None, None, ''), self)
1129
1130 def __repr__(self):
1131 return '<%s %r>' % (self.__class__.__name__, self.name)
1132
1133 @property
1134 def cache(self):
1135 return MilestoneCache(self.env)
1136
1137 exists = property(lambda self: self._old['name'] is not None)
1138 is_completed = property(lambda self: self.completed is not None)
1139 is_late = property(lambda self: self.due and
1140 self.due < datetime_now(utc))
1141
1142 def checkin(self, invalidate=True):
1143 self._old = {'name': self.name, 'due': self.due,
1144 'completed': self.completed,
1145 'description': self.description}
1146 if invalidate:
1147 del self.cache.milestones
1148
1149 def delete(self):
1150 """Delete the milestone."""
1151 self.env.log.info("Deleting milestone '%s'", self.name)
1152 with self.env.db_transaction as db:
1153 db("DELETE FROM milestone WHERE name=%s", (self.name,))
1154 Attachment.delete_all(self.env, self.realm, self.name)
1155 del self.cache.milestones
1156 TicketSystem(self.env).reset_ticket_fields()
1157 self._old['name'] = None
1158
1159 for listener in TicketSystem(self.env).milestone_change_listeners:
1160 listener.milestone_deleted(self)
1161
1162 def insert(self):
1163 """Insert a new milestone.
1164
1165 :raises TracError: if milestone name is empty.
1166 :raises ResourceExistsError: if milestone with name already exists.
1167 """
1168 self.name = simplify_whitespace(self.name)
1169 if not self.name:
1170 raise TracError(_("Invalid milestone name."))
1171
1172 self.env.log.debug("Creating new milestone '%s'", self.name)
1173 with self.env.db_transaction as db:
1174 try:
1175 db("""
1176 INSERT INTO milestone (name, due, completed, description)
1177 VALUES (%s,%s,%s,%s)
1178 """, (self.name, to_utimestamp(self.due),
1179 to_utimestamp(self.completed), self.description))
1180 except self.env.db_exc.IntegrityError:
1181 raise ResourceExistsError(
1182 _('Milestone "%(name)s" already exists.', name=self.name))
1183 self.checkin()
1184 TicketSystem(self.env).reset_ticket_fields()
1185
1186 for listener in TicketSystem(self.env).milestone_change_listeners:
1187 listener.milestone_created(self)
1188
1189 def update(self, author=None):
1190 """Update the milestone.
1191
1192 :raises TracError: if milestone does not exist or milestone name
1193 is empty.
1194 :raises ResourceExistsError: if renamed milestone already exists.
1195 """
1196 self.name = simplify_whitespace(self.name)
1197 if not self.name:
1198 raise TracError(_("Invalid milestone name."))
1199
1200 old = self._old.copy()
1201 with self.env.db_transaction as db:
1202 if self.name != old['name']:
1203 # Update milestone field in tickets
1204 self.move_tickets(self.name, author, "Milestone renamed")
1205 # Reparent attachments
1206 Attachment.reparent_all(self.env, self.realm, old['name'],
1207 self.realm, self.name)
1208
1209 self.env.log.info("Updating milestone '%s'", old['name'])
1210 try:
1211 db("""UPDATE milestone
1212 SET name=%s, due=%s, completed=%s, description=%s
1213 WHERE name=%s
1214 """, (self.name, to_utimestamp(self.due),
1215 to_utimestamp(self.completed),
1216 self.description, old['name']))
1217 except self.env.db_exc.IntegrityError:
1218 raise ResourceExistsError(
1219 _('Milestone "%(name)s" already exists.', name=self.name))
1220 self.checkin()
1221 # Fields need reset if renamed or completed/due changed
1222 TicketSystem(self.env).reset_ticket_fields()
1223
1224 old_values = {k: v for k, v in old.items()
1225 if getattr(self, k) != v}
1226 for listener in TicketSystem(self.env).milestone_change_listeners:
1227 listener.milestone_changed(self, old_values)
1228
1229 def move_tickets(self, new_milestone, author, comment=None,
1230 exclude_closed=False):
1231 """Move tickets associated with this milestone to another
1232 milestone.
1233
1234 :param new_milestone: milestone to which the tickets are moved
1235 :param author: author of the change
1236 :param comment: comment that is inserted into moved tickets. The
1237 string should not be translated.
1238 :param exclude_closed: whether tickets with status closed should be
1239 excluded
1240
1241 :return: a list of ids of tickets that were moved
1242 """
1243 # Check if milestone exists, but if the milestone is being renamed
1244 # the new milestone won't exist in the cache yet so skip the test
1245 if new_milestone and new_milestone != self.name:
1246 if not self.cache.fetchone(new_milestone):
1247 raise ResourceNotFound(
1248 _("Milestone %(name)s does not exist.",
1249 name=new_milestone), _("Invalid milestone name."))
1250 now = datetime_now(utc)
1251 sql = "SELECT id FROM ticket WHERE milestone=%s"
1252 if exclude_closed:
1253 sql += " AND status != 'closed'"
1254 with self.env.db_transaction as db:
1255 tkt_ids = [int(row[0]) for row in db(sql, (self._old['name'],))]
1256 if tkt_ids:
1257 self.env.log.info("Moving tickets associated with milestone "
1258 "'%s' to milestone '%s'", self._old['name'],
1259 new_milestone)
1260 for tkt_id in tkt_ids:
1261 ticket = Ticket(self.env, tkt_id)
1262 ticket['milestone'] = new_milestone
1263 ticket.save_changes(author, comment, now)
1264 return tkt_ids
1265
1266 @classmethod
1267 def select(cls, env, include_completed=True):
1268 milestones = MilestoneCache(env).fetchall()
1269 if not include_completed:
1270 milestones = [m for m in milestones if m.completed is None]
1271 def milestone_order(m):
1272 return (m.completed or utcmax,
1273 m.due or utcmax,
1274 embedded_numbers(m.name))
1275 return sorted(milestones, key=milestone_order)
1276
1277
1278class Report(object):
1279
1280 realm = 'report'
1281
1282 @property
1283 def exists(self):
1284 return self.id is not None
1285
1286 def __init__(self, env, id=None):
1287 self.env = env
1288 self.id = self.title = self.query = self.description = None
1289 if id is not None:
1290 id_as_int = as_int(id, None)
1291 if id_as_int is not None:
1292 for title, description, query in self.env.db_query("""
1293 SELECT title, description, query FROM report
1294 WHERE id=%s
1295 """, (id_as_int,)):
1296 self.id = id_as_int
1297 self.title = _null_to_empty(title)
1298 self.description = _null_to_empty(description)
1299 self.query = _null_to_empty(query)
1300 return
1301 raise ResourceNotFound(_("Report {%(num)s} does not exist.",
1302 num=id), _("Invalid Report Number"))
1303
1304 def __repr__(self):
1305 return '<%s %r>' % (self.__class__.__name__, self.id)
1306
1307 def delete(self):
1308 """Delete the report."""
1309 assert self.exists, "Cannot delete non-existent report"
1310 self.env.db_transaction("DELETE FROM report WHERE id=%s", (self.id,))
1311 self.id = None
1312
1313 def insert(self):
1314 """Insert a new report.
1315
1316 :raises TracError: if `query` is empty
1317 """
1318 assert not self.exists, "Cannot insert existing report"
1319 if not self.query:
1320 raise TracError(_("Query cannot be empty."))
1321
1322 self.env.log.debug("Creating new report '%s'", self.id)
1323 with self.env.db_transaction as db:
1324 cursor = db.cursor()
1325 cursor.execute("""
1326 INSERT INTO report (title,query,description) VALUES (%s,%s,%s)
1327 """, (_to_null(self.title), _to_null(self.query),
1328 _to_null(self.description)))
1329 self.id = db.get_last_id(cursor, 'report')
1330
1331 def update(self):
1332 """Update a report.
1333
1334 :raises TracError: if `query` is empty
1335 """
1336 if not self.query:
1337 raise TracError(_("Query cannot be empty."))
1338 self.env.db_transaction("""
1339 UPDATE report SET title=%s, query=%s, description=%s
1340 WHERE id=%s
1341 """, (_to_null(self.title), _to_null(self.query),
1342 _to_null(self.description), self.id))
1343
1344 @classmethod
1345 def select(cls, env, sort='id', asc=True):
1346 for id, title, description, query in env.db_query("""
1347 SELECT id, title, description, query
1348 FROM report ORDER BY %s %s
1349 """ % ('title' if sort == 'title' else 'id',
1350 '' if asc else 'DESC')):
1351 report = cls(env)
1352 report.id = id
1353 report.title = _null_to_empty(title)
1354 report.description = _null_to_empty(description)
1355 report.query = _null_to_empty(query)
1356 yield report
1357
1358
1359class Version(object):
1360
1361 realm = 'version'
1362
1363 exists = property(lambda self: self._old_name is not None)
1364
1365 @property
1366 def resource(self):
1367 return Resource(self.realm, self.name)
1368
1369 def __init__(self, env, name=None):
1370 self.env = env
1371 self.name = self._old_name = self.time = self.description = None
1372 if name:
1373 for time, description in self.env.db_query("""
1374 SELECT time, description FROM version WHERE name=%s
1375 """, (name,)):
1376 self.name = self._old_name = name
1377 self.time = _from_timestamp(time)
1378 self.description = _null_to_empty(description)
1379 break
1380 else:
1381 raise ResourceNotFound(_("Version %(name)s does not exist.",
1382 name=name))
1383
1384 def __repr__(self):
1385 return '<%s %r>' % (self.__class__.__name__, self.name)
1386
1387 def delete(self):
1388 """Delete the version.
1389
1390 :raises TracError: if version does not exist.
1391 """
1392 if not self.exists:
1393 raise TracError(_("Cannot delete non-existent version."))
1394
1395 self.env.log.info("Deleting version '%s'", self.name)
1396 with self.env.db_transaction as db:
1397 db("DELETE FROM version WHERE name=%s", (self.name,))
1398 TicketSystem(self.env).reset_ticket_fields()
1399 self.name = self._old_name = None
1400
1401 def insert(self):
1402 """Insert a new version.
1403
1404 :raises TracError: if version name is empty.
1405 :raises ResourceExistsError: if version with name already exists.
1406 """
1407 self._check_and_coerce_fields()
1408
1409 self.env.log.debug("Creating new version '%s'", self.name)
1410 with self.env.db_transaction as db:
1411 try:
1412 db("""
1413 INSERT INTO version (name,time,description)
1414 VALUES (%s,%s,%s)
1415 """, (self.name, to_utimestamp(self.time),
1416 _to_null(self.description)))
1417 except self.env.db_exc.IntegrityError:
1418 raise ResourceExistsError(
1419 _('Version "%(name)s" already exists.', name=self.name))
1420 TicketSystem(self.env).reset_ticket_fields()
1421 self._old_name = self.name
1422
1423 def update(self):
1424 """Update the version.
1425
1426 :raises TracError: if version does not exist or version name
1427 is empty.
1428 :raises ResourceExistsError: if renamed value already exists.
1429 """
1430 if not self.exists:
1431 raise TracError(_("Cannot update non-existent version."))
1432 self._check_and_coerce_fields()
1433
1434 self.env.log.info("Updating version '%s'", self.name)
1435 with self.env.db_transaction as db:
1436 try:
1437 db("""UPDATE version SET name=%s, time=%s, description=%s
1438 WHERE name=%s
1439 """, (self.name, to_utimestamp(self.time),
1440 self.description, self._old_name))
1441 except self.env.db_exc.IntegrityError:
1442 raise ResourceExistsError(
1443 _('Version "%(name)s" already exists.', name=self.name))
1444 if self.name != self._old_name:
1445 # Update tickets
1446 db("UPDATE ticket SET version=%s WHERE version=%s",
1447 (self.name, self._old_name))
1448 self._old_name = self.name
1449 # Fields need reset if renamed or if time is changed
1450 TicketSystem(self.env).reset_ticket_fields()
1451
1452 @classmethod
1453 def select(cls, env):
1454 versions = []
1455 for name, time, description in env.db_query("""
1456 SELECT name, time, description FROM version"""):
1457 version = cls(env)
1458 version.name = version._old_name = name
1459 version.time = _from_timestamp(time)
1460 version.description = _null_to_empty(description)
1461 versions.append(version)
1462 def version_order(v):
1463 return v.time or utcmax, embedded_numbers(v.name)
1464 return sorted(versions, key=version_order, reverse=True)
1465
1466 def _check_and_coerce_fields(self):
1467 self.name = simplify_whitespace(self.name)
1468 if not self.name:
1469 raise TracError(_("Invalid version name."))
Note: See TracBrowser for help on using the repository browser.