Edgewall Software

source: trunk/trac/ticket/model.py

Last change on this file was 10968, checked in by cboos, 3 months ago

Merge changes from 0.12-stable (except l10n ones)

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