Edgewall Software

source: trunk/trac/ticket/model.py

Last change on this file was 13885, checked in by rjollos, 4 months ago

1.1.4dev: Whitespace cleanup.

  • Property svn:eol-style set to native
File size: 49.8 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2014 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 http://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 http://trac.edgewall.org/log/.
16#
17# Author: Jonas Borgström <jonas@edgewall.com>
18#         Christopher Lenz <cmlenz@gmx.de>
19
20import re
21from datetime import datetime
22
23from trac import core
24from trac.attachment import Attachment
25from trac.cache import cached
26from trac.core import TracError
27from trac.resource import Resource, ResourceNotFound
28from trac.ticket.api import TicketSystem
29from trac.util import embedded_numbers
30from trac.util.datefmt import from_utimestamp, parse_date, to_utimestamp, \
31                              utc, utcmax
32from trac.util.text import empty
33from trac.util.translation import _
34
35__all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity',
36           'Component', 'Milestone', 'Version', 'group_milestones']
37
38
39def _fixup_cc_list(cc_value):
40    """Fix up cc list separators and remove duplicates."""
41    cclist = []
42    for cc in re.split(r'[;,\s]+', cc_value):
43        if cc and cc not in cclist:
44            cclist.append(cc)
45    return ', '.join(cclist)
46
47
48def _db_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
61def _datetime_to_db_str(dt, is_custom_field):
62    if not dt:
63        return ''
64    ts = to_utimestamp(dt)
65    if is_custom_field:
66        # Padding with '0' would be easy to sort in report page for a user
67        fmt = '%018d' if ts >= 0 else '%+017d'
68        return fmt % ts
69    else:
70        return ts
71
72
73class Ticket(object):
74
75    realm = 'ticket'
76
77    # Fields that must not be modified directly by the user
78    # 'owner' should eventually be a protected field (#2045)
79    protected_fields = 'resolution', 'status', 'time', 'changetime'
80
81    @staticmethod
82    def id_is_valid(num):
83        return 0 < int(num) <= 1L << 31
84
85    @property
86    def resource(self):
87        return Resource(self.realm, self.id, self.version)
88
89    # 0.11 compatibility. Will be removed in 1.3.1.
90    time_created = property(lambda self: self.values.get('time'))
91    time_changed = property(lambda self: self.values.get('changetime'))
92
93    def __init__(self, env, tkt_id=None, version=None):
94        self.env = env
95        self.fields = TicketSystem(self.env).get_ticket_fields()
96        self.editable_fields = \
97            set(f['name'] for f in self.fields
98                          if f['name'] not in self.protected_fields)
99        self.std_fields, self.custom_fields, self.time_fields = [], [], []
100        for f in self.fields:
101            if f.get('custom'):
102                self.custom_fields.append(f['name'])
103            else:
104                self.std_fields.append(f['name'])
105            if f['type'] == 'time':
106                self.time_fields.append(f['name'])
107        self.values = {}
108        if tkt_id is not None:
109            tkt_id = int(tkt_id)
110            self._fetch_ticket(tkt_id)
111        else:
112            self._init_defaults()
113            self.id = None
114        self.version = version
115        self._old = {}
116
117    def __repr__(self):
118        return '<%s %r>' % (self.__class__.__name__, self.id)
119
120    exists = property(lambda self: self.id is not None)
121
122    def _init_defaults(self):
123        for field in self.fields:
124            default = None
125            if field['name'] in self.protected_fields:
126                # Ignore for new - only change through workflow
127                pass
128            elif not field.get('custom'):
129                default = self.env.config.get('ticket',
130                                              'default_' + field['name'])
131            else:
132                default = field.get('value')
133                options = field.get('options')
134                if default and options and default not in options:
135                    try:
136                        default = options[int(default)]
137                    except (ValueError, IndexError):
138                        self.env.log.warning('Invalid default value "%s" '
139                                             'for custom field "%s"',
140                                             default, field['name'])
141                if default and field.get('type') == 'time':
142                    try:
143                        default = parse_date(default,
144                                             hint=field.get('format'))
145                    except TracError as e:
146                        self.env.log.warning('Invalid default value "%s" '
147                                             'for custom field "%s": %s',
148                                             default, field['name'], e)
149                        default = None
150            if default:
151                self.values.setdefault(field['name'], default)
152
153    def _fetch_ticket(self, tkt_id):
154        row = None
155        if self.id_is_valid(tkt_id):
156            # Fetch the standard ticket fields
157            for row in self.env.db_query("SELECT %s FROM ticket WHERE id=%%s" %
158                                         ','.join(self.std_fields), (tkt_id,)):
159                break
160        if not row:
161            raise ResourceNotFound(_("Ticket %(id)s does not exist.",
162                                     id=tkt_id), _("Invalid ticket number"))
163
164        self.id = tkt_id
165        for i, field in enumerate(self.std_fields):
166            value = row[i]
167            if field in self.time_fields:
168                self.values[field] = from_utimestamp(value)
169            elif value is None:
170                self.values[field] = empty
171            else:
172                self.values[field] = value
173
174        # Fetch custom fields if available
175        for name, value in self.env.db_query("""
176                SELECT name, value FROM ticket_custom WHERE ticket=%s
177                """, (tkt_id,)):
178            if name in self.custom_fields:
179                if name in self.time_fields:
180                    self.values[name] = _db_str_to_datetime(value)
181                elif value is None:
182                    self.values[name] = empty
183                else:
184                    self.values[name] = value
185
186    def __getitem__(self, name):
187        return self.values.get(name)
188
189    def __setitem__(self, name, value):
190        """Log ticket modifications so the table ticket_change can be updated
191        """
192        if value and name not in self.time_fields:
193            if isinstance(value, list):
194                raise TracError(_("Multi-values fields not supported yet"))
195            if self.fields.by_name(name, {}).get('type') != 'textarea':
196                value = value.strip()
197        if name in self.values and self.values[name] == value:
198            return
199        if name not in self._old:  # Changed field
200            self._old[name] = self.values.get(name)
201        elif self._old[name] == value:  # Change of field reverted
202            del self._old[name]
203        self.values[name] = value
204
205    def get_value_or_default(self, name):
206        """Return the value of a field or the default value if it is undefined
207        """
208        try:
209            value = self.values[name]
210            return value if value is not empty else self.get_default(name)
211        except KeyError:
212            pass
213
214    def get_default(self, name):
215        """Return the default value of a field."""
216        return self.fields.by_name(name, {}).get('value', '')
217
218    def populate(self, values):
219        """Populate the ticket with 'suitable' values from a dictionary"""
220        field_names = [f['name'] for f in self.fields]
221        for name in [name for name in values.keys() if name in field_names]:
222            self[name] = values[name]
223
224        # We have to do an extra trick to catch unchecked checkboxes
225        for name in [name for name in values.keys() if name[9:] in field_names
226                     and name.startswith('checkbox_')]:
227            if name[9:] not in values:
228                self[name[9:]] = '0'
229
230    def insert(self, when=None):
231        """Add ticket to database.
232        """
233        assert not self.exists, 'Cannot insert an existing ticket'
234
235        if 'cc' in self.values:
236            self['cc'] = _fixup_cc_list(self.values['cc'])
237
238        # Add a timestamp
239        if when is None:
240            when = datetime.now(utc)
241        self.values['time'] = self.values['changetime'] = when
242
243        # The owner field defaults to the component owner
244        if self.values.get('owner') == '< default >':
245            default_to_owner = ''
246            if self.values.get('component'):
247                try:
248                    component = Component(self.env, self['component'])
249                    default_to_owner = component.owner  # even if it's empty
250                except ResourceNotFound:
251                    # No such component exists
252                    pass
253            # If the current owner is "< default >", we need to set it to
254            # _something_ else, even if that something else is blank.
255            self['owner'] = default_to_owner
256
257        # Perform type conversions
258        db_values = self._to_db_types(self.values)
259
260        # Insert ticket record
261        std_fields = []
262        custom_fields = []
263        for f in self.fields:
264            fname = f['name']
265            if fname in self.values:
266                if f.get('custom'):
267                    custom_fields.append(fname)
268                else:
269                    std_fields.append(fname)
270        with self.env.db_transaction as db:
271            cursor = db.cursor()
272            cursor.execute("INSERT INTO ticket (%s) VALUES (%s)"
273                           % (','.join(std_fields),
274                              ','.join(['%s'] * len(std_fields))),
275                           [db_values.get(name) for name in std_fields])
276            tkt_id = db.get_last_id(cursor, 'ticket')
277
278            # Insert custom fields
279            if custom_fields:
280                db.executemany(
281                    """INSERT INTO ticket_custom (ticket, name, value)
282                       VALUES (%s, %s, %s)
283                    """, [(tkt_id, c, db_values.get(c))
284                          for c in custom_fields])
285
286        self.id = int(tkt_id)
287        self._old = {}
288
289        for listener in TicketSystem(self.env).change_listeners:
290            listener.ticket_created(self)
291
292        return self.id
293
294    def get_comment_number(self, cdate):
295        """Return a comment number by its date."""
296        ts = to_utimestamp(cdate)
297        for cnum, in self.env.db_query("""\
298                SELECT oldvalue FROM ticket_change
299                WHERE ticket=%s AND time=%s AND field='comment'
300                """, (self.id, ts)):
301            try:
302                return int(cnum.rsplit('.', 1)[-1])
303            except ValueError:
304                break
305
306    def save_changes(self, author=None, comment=None, when=None, cnum='',
307                     replyto=None):
308        """
309        Store ticket changes in the database. The ticket must already exist in
310        the database.  Returns False if there were no changes to save, True
311        otherwise.
312
313        :since 1.0: the `cnum` parameter is deprecated, and threading should
314        be controlled with the `replyto` argument
315        """
316        assert self.exists, "Cannot update a new ticket"
317
318        if 'cc' in self.values:
319            self['cc'] = _fixup_cc_list(self.values['cc'])
320
321        props_unchanged = all(self.values.get(k) == v
322                              for k, v in self._old.iteritems())
323        if (not comment or not comment.strip()) and props_unchanged:
324            return False  # Not modified
325
326        if when is None:
327            when = datetime.now(utc)
328        when_ts = to_utimestamp(when)
329
330        if 'component' in self.values:
331            # If the component is changed on a 'new' ticket
332            # then owner field is updated accordingly. (#623).
333            if self.values.get('status') == 'new' \
334                    and 'component' in self._old \
335                    and 'owner' not in self._old:
336                try:
337                    old_comp = Component(self.env, self._old['component'])
338                    old_owner = old_comp.owner or ''
339                    current_owner = self.values.get('owner') or ''
340                    if old_owner == current_owner:
341                        new_comp = Component(self.env, self['component'])
342                        if new_comp.owner:
343                            self['owner'] = new_comp.owner
344                except TracError:
345                    # If the old component has been removed from the database
346                    # we just leave the owner as is.
347                    pass
348
349        # Perform type conversions
350        db_values = self._to_db_types(self.values)
351        old_db_values = self._to_db_types(self._old)
352
353        with self.env.db_transaction as db:
354            db("UPDATE ticket SET changetime=%s WHERE id=%s",
355               (when_ts, self.id))
356
357            # find cnum if it isn't provided
358            if not cnum:
359                num = 0
360                for ts, old in db("""
361                        SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,'')
362                        FROM ticket_change AS tc1
363                        LEFT OUTER JOIN ticket_change AS tc2
364                        ON tc2.ticket=%s AND tc2.time=tc1.time
365                           AND tc2.field='comment'
366                        WHERE tc1.ticket=%s ORDER BY tc1.time DESC
367                        """, (self.id, self.id)):
368                    # Use oldvalue if available, else count edits
369                    try:
370                        num += int(old.rsplit('.', 1)[-1])
371                        break
372                    except ValueError:
373                        num += 1
374                cnum = str(num + 1)
375                if replyto:
376                    cnum = '%s.%s' % (replyto, cnum)
377
378            # store fields
379            for name in self._old.keys():
380                if name in self.custom_fields:
381                    for row in db("""SELECT * FROM ticket_custom
382                                     WHERE ticket=%s and name=%s
383                                     """, (self.id, name)):
384                        db("""UPDATE ticket_custom SET value=%s
385                              WHERE ticket=%s AND name=%s
386                              """, (db_values.get(name), self.id, name))
387                        break
388                    else:
389                        db("""INSERT INTO ticket_custom (ticket,name,value)
390                              VALUES(%s,%s,%s)
391                              """, (self.id, name, db_values.get(name)))
392                else:
393                    db("UPDATE ticket SET %s=%%s WHERE id=%%s"
394                       % name, (db_values.get(name), self.id))
395                db("""INSERT INTO ticket_change
396                        (ticket,time,author,field,oldvalue,newvalue)
397                      VALUES (%s, %s, %s, %s, %s, %s)
398                      """, (self.id, when_ts, author, name,
399                            old_db_values.get(name), db_values.get(name)))
400
401            # always save comment, even if empty
402            # (numbering support for timeline)
403            db("""INSERT INTO ticket_change
404                    (ticket,time,author,field,oldvalue,newvalue)
405                  VALUES (%s,%s,%s,'comment',%s,%s)
406                  """, (self.id, when_ts, author, cnum, comment))
407
408        old_values = self._old
409        self._old = {}
410        self.values['changetime'] = when
411
412        for listener in TicketSystem(self.env).change_listeners:
413            listener.ticket_changed(self, comment, author, old_values)
414        return int(cnum.rsplit('.', 1)[-1])
415
416    def _to_db_types(self, values):
417        values = values.copy()
418        for field, value in values.iteritems():
419            if field in self.time_fields:
420                is_custom_field = field in self.custom_fields
421                values[field] = _datetime_to_db_str(value, is_custom_field)
422            else:
423                values[field] = value if value else None
424        return values
425
426    def get_changelog(self, when=None):
427        """Return the changelog as a list of tuples of the form
428        (time, author, field, oldvalue, newvalue, permanent).
429
430        While the other tuple elements are quite self-explanatory,
431        the `permanent` flag is used to distinguish collateral changes
432        that are not yet immutable (like attachments, currently).
433        """
434        sid = str(self.id)
435        when_ts = to_utimestamp(when)
436        if when_ts:
437            sql = """
438                SELECT time, author, field, oldvalue, newvalue, 1 AS permanent
439                FROM ticket_change WHERE ticket=%s AND time=%s
440                  UNION
441                SELECT time, author, 'attachment', null, filename,
442                  0 AS permanent
443                FROM attachment WHERE type='ticket' AND id=%s AND time=%s
444                  UNION
445                SELECT time, author, 'comment', null, description,
446                  0 AS permanent
447                FROM attachment WHERE type='ticket' AND id=%s AND time=%s
448                ORDER BY time,permanent,author
449                """
450            args = (self.id, when_ts, sid, when_ts, sid, when_ts)
451        else:
452            sql = """
453                SELECT time, author, field, oldvalue, newvalue, 1 AS permanent
454                FROM ticket_change WHERE ticket=%s
455                  UNION
456                SELECT time, author, 'attachment', null, filename,
457                  0 AS permanent
458                FROM attachment WHERE type='ticket' AND id=%s
459                  UNION
460                SELECT time, author, 'comment', null, description,
461                  0 AS permanent
462                FROM attachment WHERE type='ticket' AND id=%s
463                ORDER BY time,permanent,author
464                """
465            args = (self.id, sid, sid)
466        log = []
467        for t, author, field, oldvalue, newvalue, permanent \
468                in self.env.db_query(sql, args):
469            if field in self.time_fields:
470                oldvalue = _db_str_to_datetime(oldvalue)
471                newvalue = _db_str_to_datetime(newvalue)
472            log.append((from_utimestamp(t), author, field,
473                        oldvalue or '', newvalue or '', permanent))
474        return log
475
476    def delete(self):
477        """Delete the ticket.
478        """
479        with self.env.db_transaction as db:
480            Attachment.delete_all(self.env, self.realm, self.id)
481            db("DELETE FROM ticket WHERE id=%s", (self.id,))
482            db("DELETE FROM ticket_change WHERE ticket=%s", (self.id,))
483            db("DELETE FROM ticket_custom WHERE ticket=%s", (self.id,))
484
485        for listener in TicketSystem(self.env).change_listeners:
486            listener.ticket_deleted(self)
487
488    def get_change(self, cnum=None, cdate=None):
489        """Return a ticket change by its number or date.
490        """
491        if cdate is None:
492            row = self._find_change(cnum)
493            if not row:
494                return
495            cdate = from_utimestamp(row[0])
496        ts = to_utimestamp(cdate)
497        fields = {}
498        change = {'date': cdate, 'fields': fields}
499        for field, author, old, new in self.env.db_query("""
500                SELECT field, author, oldvalue, newvalue
501                FROM ticket_change WHERE ticket=%s AND time=%s
502                """, (self.id, ts)):
503            fields[field] = {'author': author, 'old': old, 'new': new}
504            if field == 'comment':
505                change['author'] = author
506            elif not field.startswith('_'):
507                change.setdefault('author', author)
508        if fields:
509            return change
510
511    def delete_change(self, cnum=None, cdate=None, when=None):
512        """Delete a ticket change identified by its number or date."""
513        if cdate is None:
514            row = self._find_change(cnum)
515            if not row:
516                return
517            cdate = from_utimestamp(row[0])
518        ts = to_utimestamp(cdate)
519        if when is None:
520            when = datetime.now(utc)
521        when_ts = to_utimestamp(when)
522
523        with self.env.db_transaction as db:
524            # Find modified fields and their previous value
525            fields = [(field, old, new)
526                      for field, old, new in db("""
527                        SELECT field, oldvalue, newvalue FROM ticket_change
528                        WHERE ticket=%s AND time=%s
529                        """, (self.id, ts))
530                      if field != 'comment' and not field.startswith('_')]
531            for field, oldvalue, newvalue in fields:
532                # Find the next change
533                for next_ts, in db("""SELECT time FROM ticket_change
534                                      WHERE ticket=%s AND time>%s AND field=%s
535                                      LIMIT 1
536                                      """, (self.id, ts, field)):
537                    # Modify the old value of the next change if it is equal
538                    # to the new value of the deleted change
539                    db("""UPDATE ticket_change SET oldvalue=%s
540                          WHERE ticket=%s AND time=%s AND field=%s
541                          AND oldvalue=%s
542                          """, (oldvalue, self.id, next_ts, field, newvalue))
543                    break
544                else:
545                    # No next change, edit ticket field
546                    if field in self.std_fields:
547                        db("UPDATE ticket SET %s=%%s WHERE id=%%s"
548                           % field, (oldvalue, self.id))
549                    else:
550                        db("""UPDATE ticket_custom SET value=%s
551                              WHERE ticket=%s AND name=%s
552                              """, (oldvalue, self.id, field))
553
554            # Delete the change
555            db("DELETE FROM ticket_change WHERE ticket=%s AND time=%s",
556               (self.id, ts))
557
558            # Update last changed time
559            db("UPDATE ticket SET changetime=%s WHERE id=%s",
560               (when_ts, self.id))
561
562        self._fetch_ticket(self.id)
563
564        changes = dict((field, (oldvalue, newvalue))
565                       for field, oldvalue, newvalue in fields)
566        for listener in TicketSystem(self.env).change_listeners:
567            if hasattr(listener, 'ticket_change_deleted'):
568                listener.ticket_change_deleted(self, cdate, changes)
569
570    def modify_comment(self, cdate, author, comment, when=None):
571        """Modify a ticket comment specified by its date, while keeping a
572        history of edits.
573        """
574        ts = to_utimestamp(cdate)
575        if when is None:
576            when = datetime.now(utc)
577        when_ts = to_utimestamp(when)
578
579        with self.env.db_transaction as db:
580            # Find the current value of the comment
581            old_comment = False
582            for old_comment, in db("""
583                    SELECT newvalue FROM ticket_change
584                    WHERE ticket=%s AND time=%s AND field='comment'
585                    """, (self.id, ts)):
586                break
587            if comment == (old_comment or ''):
588                return
589
590            # Comment history is stored in fields named "_comment%d"
591            # Find the next edit number
592            fields = db("""SELECT field FROM ticket_change
593                           WHERE ticket=%%s AND time=%%s AND field %s
594                           """ % db.prefix_match(),
595                           (self.id, ts, db.prefix_match_value('_comment')))
596            rev = max(int(field[8:]) for field, in fields) + 1 if fields else 0
597            db("""INSERT INTO ticket_change
598                    (ticket,time,author,field,oldvalue,newvalue)
599                  VALUES (%s,%s,%s,%s,%s,%s)
600                  """, (self.id, ts, author, '_comment%d' % rev,
601                        old_comment or '', str(when_ts)))
602            if old_comment is False:
603                # There was no comment field, add one, find the
604                # original author in one of the other changed fields
605                for old_author, in db("""
606                        SELECT author FROM ticket_change
607                        WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1
608                        """ % db.prefix_match(),
609                        (self.id, ts, db.prefix_match_value('_'))):
610                    db("""INSERT INTO ticket_change
611                            (ticket,time,author,field,oldvalue,newvalue)
612                          VALUES (%s,%s,%s,'comment','',%s)
613                          """, (self.id, ts, old_author, comment))
614            else:
615                db("""UPDATE ticket_change SET newvalue=%s
616                      WHERE ticket=%s AND time=%s AND field='comment'
617                      """, (comment, self.id, ts))
618
619            # Update last changed time
620            db("UPDATE ticket SET changetime=%s WHERE id=%s",
621               (when_ts, self.id))
622
623        self.values['changetime'] = when
624
625        old_comment = old_comment or ''
626        for listener in TicketSystem(self.env).change_listeners:
627            if hasattr(listener, 'ticket_comment_modified'):
628                listener.ticket_comment_modified(self, cdate, author, comment,
629                                                 old_comment)
630
631    def get_comment_history(self, cnum=None, cdate=None):
632        """Retrieve the edit history of a comment identified by its number or
633        date.
634        """
635        if cdate is None:
636            row = self._find_change(cnum)
637            if not row:
638                return
639            ts0, author0, last_comment = row
640        else:
641            ts0, author0, last_comment = to_utimestamp(cdate), None, None
642        with self.env.db_query as db:
643            # Get last comment and author if not available
644            if last_comment is None:
645                last_comment = ''
646                for author0, last_comment in db("""
647                        SELECT author, newvalue FROM ticket_change
648                        WHERE ticket=%s AND time=%s AND field='comment'
649                        """, (self.id, ts0)):
650                    break
651            if author0 is None:
652                for author0, last_comment in db("""
653                        SELECT author, newvalue FROM ticket_change
654                        WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1
655                        """ % db.prefix_match(),
656                        (self.id, ts0, db.prefix_match_value('_'))):
657                    break
658                else:
659                    return
660
661            # Get all fields of the form "_comment%d"
662            rows = db("""SELECT field, author, oldvalue, newvalue
663                         FROM ticket_change
664                         WHERE ticket=%%s AND time=%%s AND field %s
665                         """ % db.prefix_match(),
666                         (self.id, ts0, db.prefix_match_value('_comment')))
667            rows = sorted((int(field[8:]), author, old, new)
668                          for field, author, old, new in rows)
669            history = []
670            for rev, author, comment, ts in rows:
671                history.append((rev, from_utimestamp(long(ts0)), author0,
672                                comment))
673                ts0, author0 = ts, author
674            history.sort()
675            rev = history[-1][0] + 1 if history else 0
676            history.append((rev, from_utimestamp(long(ts0)), author0,
677                            last_comment))
678            return history
679
680    def _find_change(self, cnum):
681        """Find a comment by its number."""
682        scnum = str(cnum)
683        with self.env.db_query as db:
684            for row in db("""
685                    SELECT time, author, newvalue FROM ticket_change
686                    WHERE ticket=%%s AND field='comment'
687                    AND (oldvalue=%%s OR oldvalue %s)
688                    """ % db.like(),
689                    (self.id, scnum, '%' + db.like_escape('.' + scnum))):
690                return row
691
692            # Fallback when comment number is not available in oldvalue
693            num = 0
694            for ts, old, author, comment in db("""
695                    SELECT DISTINCT tc1.time, COALESCE(tc2.oldvalue,''),
696                                    tc2.author, COALESCE(tc2.newvalue,'')
697                    FROM ticket_change AS tc1
698                    LEFT OUTER JOIN ticket_change AS tc2
699                    ON tc2.ticket=%s AND tc2.time=tc1.time
700                       AND tc2.field='comment'
701                    WHERE tc1.ticket=%s ORDER BY tc1.time
702                    """, (self.id, self.id)):
703                # Use oldvalue if available, else count edits
704                try:
705                    num = int(old.rsplit('.', 1)[-1])
706                except ValueError:
707                    num += 1
708                if num == cnum:
709                    break
710            else:
711                return
712
713            # Find author if NULL
714            if author is None:
715                for author, in db("""
716                        SELECT author FROM ticket_change
717                        WHERE ticket=%%s AND time=%%s AND NOT field %s LIMIT 1
718                        """ % db.prefix_match(),
719                        (self.id, ts, db.prefix_match_value('_'))):
720                    break
721            return ts, author, comment
722
723
724def simplify_whitespace(name):
725    """Strip spaces and remove duplicate spaces within names"""
726    if name:
727        return ' '.join(name.split())
728    return name
729
730
731class AbstractEnum(object):
732    type = None
733    ticket_col = None
734
735    exists = property(lambda self: self._old_value is not None)
736
737    def __init__(self, env, name=None):
738        if not self.ticket_col:
739            self.ticket_col = self.type
740        self.env = env
741        if name:
742            for value, in self.env.db_query("""
743                    SELECT value FROM enum WHERE type=%s AND name=%s
744                    """, (self.type, name)):
745                self.value = self._old_value = value
746                self.name = self._old_name = name
747                break
748            else:
749                raise ResourceNotFound(_("%(type)s %(name)s does not exist.",
750                                         type=self.type, name=name))
751        else:
752            self.value = self._old_value = None
753            self.name = self._old_name = None
754
755    def __repr__(self):
756        return '<%s %r %r>' % (self.__class__.__name__, self.name, self.value)
757
758    def delete(self):
759        """Delete the enum value.
760        """
761        assert self.exists, "Cannot delete non-existent %s" % self.type
762
763        with self.env.db_transaction as db:
764            self.env.log.info("Deleting %s %s", self.type, self.name)
765            db("DELETE FROM enum WHERE type=%s AND value=%s",
766               (self.type, self._old_value))
767            # Re-order any enums that have higher value than deleted
768            # (close gap)
769            for enum in self.select(self.env):
770                try:
771                    if int(enum.value) > int(self._old_value):
772                        enum.value = unicode(int(enum.value) - 1)
773                        enum.update()
774                except ValueError:
775                    pass  # Ignore cast error for this non-essential operation
776            TicketSystem(self.env).reset_ticket_fields()
777        self.value = self._old_value = None
778        self.name = self._old_name = None
779
780    def insert(self):
781        """Add a new enum value.
782        """
783        assert not self.exists, "Cannot insert existing %s" % self.type
784        self.name = simplify_whitespace(self.name)
785        if not self.name:
786            raise TracError(_('Invalid %(type)s name.', type=self.type))
787
788        with self.env.db_transaction as db:
789            self.env.log.debug("Creating new %s '%s'", self.type, self.name)
790            if not self.value:
791                row = db("SELECT COALESCE(MAX(%s), 0) FROM enum WHERE type=%%s"
792                         % db.cast('value', 'int'),
793                         (self.type,))
794                self.value = int(float(row[0][0])) + 1 if row else 0
795            db("INSERT INTO enum (type, name, value) VALUES (%s, %s, %s)",
796               (self.type, self.name, self.value))
797            TicketSystem(self.env).reset_ticket_fields()
798
799        self._old_name = self.name
800        self._old_value = self.value
801
802    def update(self):
803        """Update the enum value.
804        """
805        assert self.exists, "Cannot update non-existent %s" % self.type
806        self.name = simplify_whitespace(self.name)
807        if not self.name:
808            raise TracError(_("Invalid %(type)s name.", type=self.type))
809
810        with self.env.db_transaction as db:
811            self.env.log.info("Updating %s '%s'", self.type, self.name)
812            db("UPDATE enum SET name=%s,value=%s WHERE type=%s AND name=%s",
813               (self.name, self.value, self.type, self._old_name))
814            if self.name != self._old_name:
815                # Update tickets
816                db("UPDATE ticket SET %s=%%s WHERE %s=%%s"
817                   % (self.ticket_col, self.ticket_col),
818                   (self.name, self._old_name))
819                TicketSystem(self.env).reset_ticket_fields()
820
821        self._old_name = self.name
822        self._old_value = self.value
823
824    @classmethod
825    def select(cls, env):
826        with env.db_query as db:
827            for name, value in db("""
828                    SELECT name, value FROM enum WHERE type=%s ORDER BY
829                    """ + db.cast('value', 'int'),
830                    (cls.type,)):
831                obj = cls(env)
832                obj.name = obj._old_name = name
833                obj.value = obj._old_value = value
834                yield obj
835
836
837class Type(AbstractEnum):
838    type = 'ticket_type'
839    ticket_col = 'type'
840
841
842class Status(object):
843    def __init__(self, env):
844        self.env = env
845
846    @classmethod
847    def select(cls, env):
848        for state in TicketSystem(env).get_all_status():
849            status = cls(env)
850            status.name = state
851            yield status
852
853    def __repr__(self):
854        return '<%s %r>' % (self.__class__.__name__, self.name)
855
856
857class Resolution(AbstractEnum):
858    type = 'resolution'
859
860
861class Priority(AbstractEnum):
862    type = 'priority'
863
864
865class Severity(AbstractEnum):
866    type = 'severity'
867
868
869class Component(object):
870
871    exists = property(lambda self: self._old_name is not None)
872
873    def __init__(self, env, name=None):
874        self.env = env
875        self.name = self._old_name = self.owner = self.description = None
876        if name:
877            for owner, description in self.env.db_query("""
878                    SELECT owner, description FROM component WHERE name=%s
879                    """, (name,)):
880                self.name = self._old_name = name
881                self.owner = owner or None
882                self.description = description or ''
883                break
884            else:
885                raise ResourceNotFound(_("Component %(name)s does not exist.",
886                                         name=name))
887
888    def __repr__(self):
889        return '<%s %r>' % (self.__class__.__name__, self.name)
890
891    def delete(self):
892        """Delete the component.
893        """
894        assert self.exists, "Cannot delete non-existent component"
895
896        with self.env.db_transaction as db:
897            self.env.log.info("Deleting component %s", self.name)
898            db("DELETE FROM component WHERE name=%s", (self.name,))
899            self.name = self._old_name = None
900            TicketSystem(self.env).reset_ticket_fields()
901
902    def insert(self):
903        """Insert a new component.
904        """
905        assert not self.exists, "Cannot insert existing component"
906        self.name = simplify_whitespace(self.name)
907        if not self.name:
908            raise TracError(_("Invalid component name."))
909
910        with self.env.db_transaction as db:
911            self.env.log.debug("Creating new component '%s'", self.name)
912            db("""INSERT INTO component (name,owner,description)
913                  VALUES (%s,%s,%s)
914                  """, (self.name, self.owner, self.description))
915            self._old_name = self.name
916            TicketSystem(self.env).reset_ticket_fields()
917
918    def update(self):
919        """Update the component.
920        """
921        assert self.exists, "Cannot update non-existent component"
922        self.name = simplify_whitespace(self.name)
923        if not self.name:
924            raise TracError(_("Invalid component name."))
925
926        with self.env.db_transaction as db:
927            self.env.log.info("Updating component '%s'", self.name)
928            db("""UPDATE component SET name=%s,owner=%s, description=%s
929                  WHERE name=%s
930                  """, (self.name, self.owner, self.description,
931                        self._old_name))
932            if self.name != self._old_name:
933                # Update tickets
934                db("UPDATE ticket SET component=%s WHERE component=%s",
935                   (self.name, self._old_name))
936                self._old_name = self.name
937                TicketSystem(self.env).reset_ticket_fields()
938
939    @classmethod
940    def select(cls, env):
941        for name, owner, description in env.db_query("""
942                SELECT name, owner, description FROM component ORDER BY name
943                """):
944            component = cls(env)
945            component.name = component._old_name = name
946            component.owner = owner or None
947            component.description = description or ''
948            yield component
949
950
951class MilestoneCache(core.Component):
952    """Cache for milestone data and factory for 'milestone' resources."""
953
954    @cached
955    def milestones(self):
956        """Dictionary containing milestone data, indexed by name.
957
958        Milestone data consist of a tuple containing the name, the
959        datetime objects for due and completed dates and the
960        description.
961        """
962        milestones = {}
963        for name, due, completed, description in self.env.db_query("""
964                SELECT name, due, completed, description FROM milestone
965                """):
966            milestones[name] = (name,
967                    from_utimestamp(due) if due else None,
968                    from_utimestamp(completed) if completed else None,
969                    description or '')
970        return milestones
971
972    def fetchone(self, name, milestone=None):
973        """Retrieve an existing milestone having the given `name`.
974
975        If `milestone` is specified, fill that instance instead of creating
976        a fresh one.
977
978        :return: `None` if no such milestone exists
979        """
980        data = self.milestones.get(name)
981        if data:
982            return self.factory(data, milestone)
983
984    def fetchall(self):
985        """Iterator on all milestones."""
986        for data in self.milestones.itervalues():
987            yield self.factory(data)
988
989    def factory(self, (name, due, completed, description), milestone=None):
990        """Build a `Milestone` object from milestone data.
991
992        That instance remains *private*, i.e. can't be retrieved by
993        name by other processes or even by other threads in the same
994        process, until its `~Milestone.insert` method gets called with
995        success.
996        """
997        milestone = milestone or Milestone(self.env)
998        milestone.name = name
999        milestone.due = due
1000        milestone.completed = completed
1001        milestone.description = description
1002        milestone.checkin(invalidate=False)
1003        return milestone
1004
1005
1006class Milestone(object):
1007
1008    realm = 'milestone'
1009
1010    @property
1011    def resource(self):
1012        return Resource(self.realm, self.name)  ### .version !!!
1013
1014    def __init__(self, env, name=None):
1015        """Create an undefined milestone or fetch one from the database,
1016        if `name` is given.
1017
1018        In the latter case however, raise `~trac.resource.ResourceNotFound`
1019        if a milestone of that name doesn't exist yet.
1020        """
1021        self.env = env
1022        if name:
1023            if not self.cache.fetchone(name, self):
1024                raise ResourceNotFound(
1025                    _("Milestone %(name)s does not exist.",
1026                      name=name), _("Invalid milestone name."))
1027        else:
1028            self.cache.factory((None, None, None, ''), self)
1029
1030    def __repr__(self):
1031        return '<%s %r>' % (self.__class__.__name__, self.name)
1032
1033    @property
1034    def cache(self):
1035        return MilestoneCache(self.env)
1036
1037    exists = property(lambda self: self._old['name'] is not None)
1038    is_completed = property(lambda self: self.completed is not None)
1039    is_late = property(lambda self: self.due and
1040                                    self.due < datetime.now(utc))
1041
1042    def checkin(self, invalidate=True):
1043        self._old = {'name': self.name, 'due': self.due,
1044                     'completed': self.completed,
1045                     'description': self.description}
1046        if invalidate:
1047            del self.cache.milestones
1048
1049    def delete(self, retarget_to=None, author=None):
1050        """Delete the milestone.
1051
1052        :since 1.0.2: the `retarget_to` and `author` parameters are
1053                      deprecated and will be removed in Trac 1.3.1. Tickets
1054                      should be moved to another milestone by calling
1055                      `move_tickets` before `delete`.
1056        """
1057        with self.env.db_transaction as db:
1058            self.env.log.info("Deleting milestone %s", self.name)
1059            db("DELETE FROM milestone WHERE name=%s", (self.name,))
1060            Attachment.delete_all(self.env, self.realm, self.name)
1061            # Don't translate ticket comment (comment:40:ticket:5658)
1062            self.move_tickets(retarget_to, author, "Milestone deleted")
1063            self._old['name'] = None
1064            del self.cache.milestones
1065            TicketSystem(self.env).reset_ticket_fields()
1066
1067        for listener in TicketSystem(self.env).milestone_change_listeners:
1068            listener.milestone_deleted(self)
1069
1070    def insert(self):
1071        """Insert a new milestone.
1072        """
1073        self.name = simplify_whitespace(self.name)
1074        if not self.name:
1075            raise TracError(_("Invalid milestone name."))
1076
1077        with self.env.db_transaction as db:
1078            self.env.log.debug("Creating new milestone '%s'", self.name)
1079            db("""INSERT INTO milestone (name, due, completed, description)
1080                  VALUES (%s,%s,%s,%s)
1081                  """, (self.name, to_utimestamp(self.due),
1082                        to_utimestamp(self.completed), self.description))
1083            self.checkin()
1084            TicketSystem(self.env).reset_ticket_fields()
1085
1086        for listener in TicketSystem(self.env).milestone_change_listeners:
1087            listener.milestone_created(self)
1088
1089    def update(self, author=None):
1090        """Update the milestone.
1091        """
1092        self.name = simplify_whitespace(self.name)
1093        if not self.name:
1094            raise TracError(_("Invalid milestone name."))
1095
1096        old = self._old.copy()
1097        with self.env.db_transaction as db:
1098            if self.name != old['name']:
1099                # Update milestone field in tickets
1100                self.move_tickets(self.name, author, "Milestone renamed")
1101                # Reparent attachments
1102                Attachment.reparent_all(self.env, self.realm, old['name'],
1103                                        self.realm, self.name)
1104
1105            self.env.log.info("Updating milestone '%s'", old['name'])
1106            db("""UPDATE milestone
1107                  SET name=%s, due=%s, completed=%s, description=%s
1108                  WHERE name=%s
1109                  """, (self.name, to_utimestamp(self.due),
1110                        to_utimestamp(self.completed),
1111                        self.description, old['name']))
1112            self.checkin()
1113        # Fields need reset if renamed or completed/due changed
1114        TicketSystem(self.env).reset_ticket_fields()
1115
1116        old_values = dict((k, v) for k, v in old.iteritems()
1117                          if getattr(self, k) != v)
1118        for listener in TicketSystem(self.env).milestone_change_listeners:
1119            listener.milestone_changed(self, old_values)
1120
1121    def move_tickets(self, new_milestone, author, comment=None,
1122                     exclude_closed=False):
1123        """Move tickets associated with this milestone to another
1124        milestone.
1125
1126        :param new_milestone: milestone to which the tickets are moved
1127        :param author: author of the change
1128        :param comment: comment that is inserted into moved tickets. The
1129                        string should not be translated.
1130        :param exclude_closed: whether tickets with status closed should be
1131                               excluded
1132
1133        :return: a list of ids of tickets that were moved
1134        """
1135        # Check if milestone exists, but if the milestone is being renamed
1136        # the new milestone won't exist in the cache yet so skip the test
1137        if new_milestone and new_milestone != self.name:
1138            if not self.cache.fetchone(new_milestone):
1139                raise ResourceNotFound(
1140                    _("Milestone %(name)s does not exist.",
1141                      name=new_milestone), _("Invalid milestone name."))
1142        now = datetime.now(utc)
1143        with self.env.db_transaction as db:
1144            sql = "SELECT id FROM ticket WHERE milestone=%s"
1145            if exclude_closed:
1146                sql += " AND status != 'closed'"
1147            tkt_ids = [int(row[0]) for row in db(sql, (self._old['name'],))]
1148            if tkt_ids:
1149                self.env.log.info("Moving tickets associated with milestone "
1150                                  "'%s' to milestone '%s'", self._old['name'],
1151                                  new_milestone)
1152                for tkt_id in tkt_ids:
1153                    ticket = Ticket(self.env, tkt_id)
1154                    ticket['milestone'] = new_milestone
1155                    ticket.save_changes(author, comment, now)
1156        return tkt_ids
1157
1158    def get_num_tickets(self):
1159        """Returns the number of tickets associated with the milestone.
1160        """
1161        return self.env.db_query("""
1162            SELECT COUNT(*) FROM ticket WHERE milestone=%s
1163            """, (self.name,))[0][0]
1164
1165    @classmethod
1166    def select(cls, env, include_completed=True):
1167        milestones = MilestoneCache(env).fetchall()
1168        if not include_completed:
1169            milestones = [m for m in milestones if m.completed is None]
1170        def milestone_order(m):
1171            return (m.completed or utcmax,
1172                    m.due or utcmax,
1173                    embedded_numbers(m.name))
1174        return sorted(milestones, key=milestone_order)
1175
1176
1177def group_milestones(milestones, include_completed):
1178    """Group milestones into "open with due date", "open with no due date",
1179    and possibly "completed". Return a list of (label, milestones) tuples.
1180
1181    :since 1.1.3: the function has been moved to `trac.ticket.roadmap`. It
1182                  will be removed from `trac.ticket.model` in 1.3.1.
1183    """
1184    from trac.ticket.roadmap import group_milestones
1185    return group_milestones(milestones, include_completed)
1186
1187
1188class Version(object):
1189
1190    exists = property(lambda self: self._old_name is not None)
1191
1192    def __init__(self, env, name=None):
1193        self.env = env
1194        self.name = self._old_name = self.time = self.description = None
1195        if name:
1196            for time, description in self.env.db_query("""
1197                    SELECT time, description FROM version WHERE name=%s
1198                    """, (name,)):
1199                self.name = self._old_name = name
1200                self.time = from_utimestamp(time) if time else None
1201                self.description = description or ''
1202                break
1203            else:
1204                raise ResourceNotFound(_("Version %(name)s does not exist.",
1205                                         name=name))
1206
1207    def __repr__(self):
1208        return '<%s %r>' % (self.__class__.__name__, self.name)
1209
1210    def delete(self):
1211        """Delete the version.
1212        """
1213        assert self.exists, "Cannot delete non-existent version"
1214
1215        with self.env.db_transaction as db:
1216            self.env.log.info("Deleting version %s", self.name)
1217            db("DELETE FROM version WHERE name=%s", (self.name,))
1218            self.name = self._old_name = None
1219            TicketSystem(self.env).reset_ticket_fields()
1220
1221    def insert(self):
1222        """Insert a new version.
1223        """
1224        assert not self.exists, "Cannot insert existing version"
1225        self.name = simplify_whitespace(self.name)
1226        if not self.name:
1227            raise TracError(_("Invalid version name."))
1228
1229        with self.env.db_transaction as db:
1230            self.env.log.debug("Creating new version '%s'", self.name)
1231            db("INSERT INTO version (name,time,description) VALUES (%s,%s,%s)",
1232                (self.name, to_utimestamp(self.time), self.description))
1233            self._old_name = self.name
1234            TicketSystem(self.env).reset_ticket_fields()
1235
1236    def update(self):
1237        """Update the version.
1238        """
1239        assert self.exists, "Cannot update non-existent version"
1240        self.name = simplify_whitespace(self.name)
1241        if not self.name:
1242            raise TracError(_("Invalid version name."))
1243
1244        with self.env.db_transaction as db:
1245            self.env.log.info("Updating version '%s'", self.name)
1246            db("""UPDATE version
1247                  SET name=%s, time=%s, description=%s WHERE name=%s
1248                  """, (self.name, to_utimestamp(self.time), self.description,
1249                        self._old_name))
1250            if self.name != self._old_name:
1251                # Update tickets
1252                db("UPDATE ticket SET version=%s WHERE version=%s",
1253                   (self.name, self._old_name))
1254                self._old_name = self.name
1255        # Fields need reset if renamed or if time is changed
1256        TicketSystem(self.env).reset_ticket_fields()
1257
1258    @classmethod
1259    def select(cls, env):
1260        versions = []
1261        for name, time, description in env.db_query("""
1262                SELECT name, time, description FROM version"""):
1263            version = cls(env)
1264            version.name = version._old_name = name
1265            version.time = from_utimestamp(time) if time else None
1266            version.description = description or ''
1267            versions.append(version)
1268        def version_order(v):
1269            return v.time or utcmax, embedded_numbers(v.name)
1270        return sorted(versions, key=version_order, reverse=True)
Note: See TracBrowser for help on using the repository browser.