Edgewall Software

root/trunk/trac/ticket/model.py

Revision 9161, 41.6 KB checked in by cboos, 3 days ago (diff)

Cosmetic follow-up to r9158.

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