Edgewall Software

NewWorkflow: patch-newworkflow-r1098.diff

File patch-newworkflow-r1098.diff , 90.3 KB (added by pkou <pkou at ua.fm>, 7 years ago)

Patch synchronized with Trac 0.8

Line 
1Index: htdocs/css/timeline.css
2===================================================================
3--- htdocs/css/timeline.css     (revision 1098)
4+++ htdocs/css/timeline.css     (working copy)
5@@ -35,6 +35,8 @@
6 /* Apply icon background-image twice to avoid hover-flicker in IE/Win */
7 dt.changeset, dt.changeset a { background-image: url(../changeset.png) !important }
8 dt.newticket, dt.newticket a { background-image: url(../newticket.png) !important }
9+dt.resolvedticket, dt.resolvedticket a { background-image: url(../resolvedticket.png) !important }
10+dt.reopenedticket, dt.reopenedticket a { background-image: url(../reopenedticket.png) !important }
11 dt.closedticket, dt.closedticket a { background-image: url(../closedticket.png) !important }
12 dt.wiki, dt.wiki a { background-image: url(../wiki.png) !important }
13 dt.milestone, dt.milestone a { background-image: url(../milestone.png) !important }
14Index: wiki-default/TracIni
15===================================================================
16--- wiki-default/TracIni        (revision 1098)
17+++ wiki-default/TracIni        (working copy)
18@@ -27,6 +27,7 @@
19 See also: TracLogging
20 
21 == [ticket] ==
22+|| workflow || Ticket workflow class.  If not specified, it is ''trac.workflows.SimpleWorkflow'' ||
23 || default_version   || Default version for newly created tickets ||
24 || default_severity  || Default severity for newly created tickets ||
25 || default_priority  || Default priority for newly created tickets ||
26@@ -66,4 +67,4 @@
27 
28 [[BR]]
29 ----
30-See also: TracGuide, TracAdmin
31\ No newline at end of file
32+See also: TracGuide, TracAdmin
33Index: wiki-default/TracAdmin
34===================================================================
35--- wiki-default/TracAdmin      (revision 1098)
36+++ wiki-default/TracAdmin      (working copy)
37@@ -28,10 +28,10 @@
38 permission add <user> <action> [action] [...]     -- Add a new permission rule                             
39 permission remove <user> <action> [action] [...]  -- Remove permission rule                               
40 component list                                    -- Show available components                             
41-component add <name> <owner>                      -- Add a new component                                   
42+component add <name> <owner> [<qaowner>]          -- Add a new component                                   
43 component rename <name> <newname>                 -- Rename a component                                   
44 component remove <name>                           -- Remove/uninstall component                           
45-component chown <name> <owner>                    -- Change component ownership                           
46+component chown <name> <owner> [<qaowner>]        -- Change component ownership                           
47 priority list                                     -- Show possible ticket priorities                       
48 priority add <value>                              -- Add a priority value option                           
49 priority change <value> <newvalue>                -- Change a priority value                               
50@@ -46,10 +46,11 @@
51 version time <name> <time>                        -- Set version date (Format: "Jun 3, 2003")             
52 version remove <name>                             -- Remove version                                       
53 milestone list                                    -- Show milestones                                       
54-milestone add <name> [time]                       -- Add milestone                                         
55+milestone add <name> [<owner> [time]]             -- Add milestone                                         
56 milestone rename <name> <newname>                 -- Rename milestone                                     
57 milestone time <name> <time>                      -- Set milestone date (Format: "Jun 3, 2003")           
58 milestone remove <name>                           -- Remove milestone                                     
59+milestone chown <name> <owner>                    -- Change milestone ownership
60 }}}
61 
62 == Interactive Mode ==
63@@ -63,4 +64,4 @@
64 
65 
66 ----
67-See Also: TracGuide, TracBackup, TracPermissions. TracEnvironment, TracIni
68\ No newline at end of file
69+See Also: TracGuide, TracBackup, TracPermissions. TracEnvironment, TracIni
70Index: setup.py
71===================================================================
72--- setup.py    (revision 1098)
73+++ setup.py    (working copy)
74@@ -198,7 +198,8 @@
75       author_email="info@edgewall.com",
76       license=LICENSE,
77       url=URL,
78-      packages=['trac', 'trac.upgrades', 'trac.wikimacros', 'trac.mimeviewers'],
79+      packages=['trac', 'trac.upgrades', 'trac.wikimacros', 'trac.mimeviewers',
80+                'trac.workflows'],
81       data_files=[(_p('share/trac/templates'), glob('templates/*')),
82                   (_p('share/trac/htdocs'), glob(_p('htdocs/*.*')) + [_p('htdocs/README')]),
83                   (_p('share/trac/htdocs/css'), glob(_p('htdocs/css/*'))),
84Index: scripts/trac-admin
85===================================================================
86--- scripts/trac-admin  (revision 1098)
87+++ scripts/trac-admin  (working copy)
88@@ -290,10 +290,10 @@
89     
90 #    ## Component
91     _help_component = [('component list', 'Show available components'),
92-                       ('component add <name> <owner>', 'Add a new component'),
93+                       ('component add <name> <owner> [<qaowner>]', 'Add a new component'),
94                        ('component rename <name> <newname>', 'Rename a component'),
95                        ('component remove <name>', 'Remove/uninstall component'),
96-                       ('component chown <name> <owner>', 'Change component ownership')]
97+                       ('component chown <name> <owner> [<qaowner>]', 'Change component ownership')]
98 
99     def complete_component (self, text, line, begidx, endidx):
100         if begidx in [16,17]:
101@@ -309,10 +309,14 @@
102         try:
103             if arg[0]  == 'list':
104                 self._do_component_list()
105-            elif arg[0] == 'add' and len(arg)==3:
106+            elif arg[0] == 'add' and len(arg) in [3,4]:
107                 name = arg[1]
108                 owner = arg[2]
109-                self._do_component_add(name, owner)
110+                if len(arg) == 4:
111+                    qaowner = arg[3]
112+                else:
113+                    qaowner = owner
114+                self._do_component_add(name, owner, qaowner)
115             elif arg[0] == 'rename' and len(arg)==3:
116                 name = arg[1]
117                 newname = arg[2]
118@@ -320,22 +324,26 @@
119             elif arg[0] == 'remove'  and len(arg)==2:
120                 name = arg[1]
121                 self._do_component_remove(name)
122-            elif arg[0] == 'chown' and len(arg)==3:
123+            elif arg[0] == 'chown' and len(arg) in [3,4]:
124                 name = arg[1]
125                 owner = arg[2]
126-                self._do_component_set_owner(name, owner)
127+                if len(arg) == 4:
128+                    qaowner = arg[3]
129+                else:
130+                    qaowner = owner
131+                self._do_component_set_owner(name, owner, qaowner)
132             else:   
133                 self.do_help ('component')
134         except Exception, e:
135             print 'Component %s failed:' % arg[0], e
136 
137     def _do_component_list(self):
138-        data = self.db_execsql('SELECT name, owner FROM component')
139-        self.print_listing(['Name', 'Owner'], data)
140+        data = self.db_execsql('SELECT name, owner, qaowner FROM component')
141+        self.print_listing(['Name', 'Owner', 'QA Owner'], data)
142 
143-    def _do_component_add(self, name, owner):
144-        data = self.db_execsql("INSERT INTO component VALUES('%s', '%s')"
145-                               % (name, owner))
146+    def _do_component_add(self, name, owner, qaowner):
147+        data = self.db_execsql("INSERT INTO component VALUES('%s', '%s', '%s')"
148+                               % (name,owner,qaowner))
149 
150     def _do_component_rename(self, name, newname):
151         cnx = self.db_open()
152@@ -360,15 +368,15 @@
153         data = self.db_execsql("DELETE FROM component WHERE name='%s'"
154                                % (name))
155 
156-    def _do_component_set_owner(self, name, owner):
157+    def _do_component_set_owner(self, name, owner, qaowner):
158         cnx = self.db_open()
159         cursor = cnx.cursor ()
160         cursor.execute('SELECT name FROM component WHERE name=%s', name)
161         data = cursor.fetchone()
162         if not data:
163             raise Exception("No such component '%s'" % name)
164-        data = self.db_execsql("UPDATE component SET owner='%s' WHERE name='%s'"
165-                               % (owner,name))
166+        data = self.db_execsql("UPDATE component SET owner='%s', qaowner='%s' WHERE name='%s'"
167+                               % (owner,qaowner,name))
168 
169 
170     ## Permission
171@@ -795,9 +803,10 @@
172 
173     ## Milestone
174     _help_milestone = [('milestone list', 'Show milestones'),
175-                       ('milestone add <name> [time]', 'Add milestone'),
176+                       ('milestone add <name> [<owner> [time]]', 'Add milestone'),
177                        ('milestone rename <name> <newname>',
178                         'Rename milestone'),
179+                       ('milestone chown <name> <newowner>', 'Change milestone owner'),
180                        ('milestone time <name> <time>', 'Set milestone date (Format: "Jun 3, 2003")'),
181                        ('milestone remove <name>', 'Remove milestone')]
182 
183@@ -805,14 +814,54 @@
184 
185         if begidx in [15,17]:
186             comp = self.get_milestone_list ()
187+        elif begidx > 15 and line.startswith('milestone chown '):
188+            comp = self.get_user_list()
189         elif begidx < 15:
190-            comp = ['list','add','rename','time','remove']
191+            comp = ['list','add','rename','chown','time','remove']
192         return self.word_complete(text, comp)
193 
194     def do_milestone(self, line):
195-        self._do_mile_ver('milestone', line)
196+        type = 'milestone'
197+        arg = self.arg_tokenize(line)
198+        try:
199+            if arg[0]  == 'list':
200+                self._do_milestone_list()
201+            elif arg[0] == 'add' and len(arg) in [2,3,4]:
202+                name = arg[1]
203+                self._do_mile_ver_add(type, name)
204+                if len(arg) >= 3:
205+                    owner = arg[2]
206+                    self._do_mile_ver_chown(type, name, owner)
207+                if len(arg) >= 4:
208+                    time = arg[3]
209+                    self._do_mile_ver_time(type, name, time)
210+            elif arg[0] == 'rename' and len(arg)==3:
211+                name = arg[1]
212+                newname = arg[2]
213+                self._do_mile_ver_rename(type, name, newname)
214+            elif arg[0] == 'chown' and len(arg)==3:
215+                name = arg[1]
216+                owner = arg[2]
217+                self._do_mile_ver_chown(type, name, owner)
218+            elif arg[0] == 'time' and len(arg)==3:
219+                name = arg[1]
220+                time = arg[2]
221+                self._do_mile_ver_time(type, name, time)
222+            elif arg[0] == 'remove' and len(arg)==2:
223+                name = arg[1]
224+                self._do_mile_ver_remove(type, name)
225+            else:
226+                self.do_help (type)
227+        except Exception, e:
228+            print 'Command %s failed:' % arg[0], e
229 
230+    def _do_milestone_list(self):
231+        data = self.db_execsql("SELECT name,owner,time FROM milestone ORDER BY time,name")
232+        data = map(lambda x: (x[0], x[1], x[2] and time.strftime('%c', time.localtime(x[2]))), data)
233+        #print data
234+        self.print_listing(['Name', 'Owner', 'Time'], data)
235 
236+
237     ## Version
238     _help_version = [('version list', 'Show versions'),
239                        ('version add <name> [time]', 'Add version'),
240@@ -832,7 +881,7 @@
241     def do_version(self, line):
242         self._do_mile_ver('version', line)
243 
244-    # Milestone and Version are identical,  methods
245+    # Milestone and Version are almost identical,  methods
246 
247     def _do_mile_ver(self, type, line):
248         arg = self.arg_tokenize(line)
249@@ -923,6 +972,10 @@
250         else:
251             print >> sys.stderr, 'Unknown time format'
252 
253+    def _do_mile_ver_chown(self, type, name, owner):
254+        data = self.db_execsql("UPDATE %s SET owner='%s' WHERE name='%s'"
255+                               % (type, owner, name));
256+
257     _help_upgrade = [('upgrade', 'Upgrade database to current version.')]
258     def do_upgrade(self, line):
259         arg = self.arg_tokenize(line)
260Index: trac/db_default.py
261===================================================================
262--- trac/db_default.py  (revision 1098)
263+++ trac/db_default.py  (working copy)
264@@ -21,7 +21,7 @@
265 
266 
267 # Database version identifier. Used for automatic upgrades.
268-db_version = 7
269+db_version = 8
270 
271 def __mkreports(reps):
272     """Utility function used to create report data in same syntax as the
273@@ -125,12 +125,14 @@
274 );
275 CREATE TABLE component (
276          name            text PRIMARY KEY,
277-         owner           text
278+         owner           text,
279+         qaowner         text
280 );
281 CREATE TABLE milestone (
282          id              integer PRIMARY KEY,
283          name            text,
284          time            integer,
285+         owner           text,
286          descr           text,
287          UNIQUE(name)
288 );
289@@ -209,8 +211,8 @@
290 """,
291 """
292 SELECT p.value AS __color__,
293-   version AS __group__,
294-   id AS ticket, summary, component, version, severity,
295+   (CASE WHEN IFNULL(version, '') = '' THEN 'Not Specified' ELSE 'Version ' || version END) AS __group__,
296+   id AS ticket, summary, component, milestone, severity,
297    (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
298    time AS created,
299    changetime AS _changetime, description AS _description,
300@@ -218,10 +220,10 @@
301   FROM ticket t, enum p
302   WHERE status IN ('new', 'assigned', 'reopened')
303 AND p.name = t.priority AND p.type = 'priority'
304-  ORDER BY (version IS NULL),version, p.value, severity, time
305+  ORDER BY (IFNULL(version, '') = '') DESC,version, p.value, severity, time
306 """),
307 #----------------------------------------------------------------------------
308-('All Tickets by Milestone',
309+('Active Tickets by Milestone',
310 """
311 This report shows how to color results by priority,
312 while grouping results by milestone.
313@@ -231,7 +233,7 @@
314 """,
315 """
316 SELECT p.value AS __color__,
317-   milestone||' Release' AS __group__,
318+   (CASE WHEN IFNULL(milestone, '') = '' THEN 'Not Assigned' ELSE milestone||' Release' END) AS __group__,
319    id AS ticket, summary, component, version, severity,
320    (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
321    time AS created,
322@@ -240,7 +242,7 @@
323   FROM ticket t, enum p
324   WHERE status IN ('new', 'assigned', 'reopened')
325 AND p.name = t.priority AND p.type = 'priority'
326-  ORDER BY (milestone IS NULL),milestone, p.value, severity, time
327+  ORDER BY (IFNULL(milestone, '') = '') DESC,milestone, p.value, severity, time
328 """),
329 #----------------------------------------------------------------------------
330 ('Assigned, Active Tickets by Owner',
331@@ -248,16 +250,15 @@
332 List assigned tickets, group by ticket owner, sorted by priority.
333 """,
334 """
335-
336 SELECT p.value AS __color__,
337-   owner AS __group__,
338-   id AS ticket, summary, component, milestone, severity, time AS created,
339+   (CASE WHEN IFNULL(owner, '') = '' THEN 'Not Assigned' ELSE owner END) AS __group__,
340+   id AS ticket, summary, component, version, milestone, severity, time AS created,
341    changetime AS _changetime, description AS _description,
342    reporter AS _reporter
343   FROM ticket t,enum p
344   WHERE status = 'assigned'
345 AND p.name=t.priority AND p.type='priority'
346-  ORDER BY owner, p.value, severity, time
347+  ORDER BY (IFNULL(owner, '') = '') DESC, owner, p.value, severity, time
348 """),
349 #----------------------------------------------------------------------------
350 ('Assigned, Active Tickets by Owner (Full Description)',
351@@ -268,7 +269,7 @@
352 """
353 SELECT p.value AS __color__,
354    owner AS __group__,
355-   id AS ticket, summary, component, milestone, severity, time AS created,
356+   id AS ticket, summary, component, version, milestone, severity, time AS created,
357    description AS _description_,
358    changetime AS _changetime, reporter AS _reporter
359   FROM ticket t, enum p
360@@ -283,7 +284,7 @@
361 """,
362 """
363 SELECT p.value AS __color__,
364-   t.milestone AS __group__,
365+   (CASE WHEN IFNULL(t.milestone, '') = '' THEN 'Not Assigned' ELSE t.milestone || ' Release' END) AS __group__,
366    (CASE status
367       WHEN 'closed' THEN 'color: #777; background: #ddd; border-color: #ccc;'
368       ELSE
369@@ -295,7 +296,7 @@
370    time AS _time,reporter AS _reporter
371   FROM ticket t,enum p
372   WHERE p.name=t.priority AND p.type='priority'
373-  ORDER BY (milestone IS NULL), milestone DESC, (status = 'closed'),
374+  ORDER BY (IFNULL(milestone, '') = '') DESC, milestone DESC, (status = 'closed'),
375         (CASE status WHEN 'closed' THEN modified ELSE -p.value END) DESC
376 """),
377 #----------------------------------------------------------------------------
378@@ -308,12 +309,12 @@
379 """
380 SELECT p.value AS __color__,
381    (CASE status WHEN 'assigned' THEN 'Assigned' ELSE 'Owned' END) AS __group__,
382-   id AS ticket, summary, component, version, milestone,
383+   id AS ticket, summary, component, status, version, milestone,
384    severity, priority, time AS created,
385    changetime AS _changetime, description AS _description,
386    reporter AS _reporter
387   FROM ticket t, enum p
388-  WHERE t.status IN ('new', 'assigned', 'reopened')
389+  WHERE t.status <> 'closed'
390 AND p.name = t.priority AND p.type = 'priority' AND owner = '$USER'
391   ORDER BY (status = 'assigned') DESC, p.value, milestone, severity, time
392 """),
393@@ -338,6 +339,173 @@
394   WHERE status IN ('new', 'assigned', 'reopened')
395 AND p.name = t.priority AND p.type = 'priority'
396   ORDER BY (owner = '$USER') DESC, p.value, milestone, severity, time
397+"""),
398+#----------------------------------------------------------------------------
399+('Open Tickets, Mine first',
400+"""
401+ * List all not closed tickets by priority.
402+ * Show all tickets owned by the logged in user in a group first.
403+""",
404+"""
405+SELECT p.value AS __color__,
406+   (CASE owner
407+     WHEN '$USER' THEN 'My Tickets'
408+     ELSE 'Open Tickets'
409+    END) AS __group__,
410+   id AS ticket, summary, component, status, version, milestone, severity,
411+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
412+   time AS created,
413+   changetime AS _changetime, description AS _description,
414+   reporter AS _reporter
415+  FROM ticket t, enum p
416+  WHERE status <> 'closed'
417+AND p.name = t.priority AND p.type = 'priority'
418+  ORDER BY (owner = '$USER') DESC, p.value, milestone, severity, time
419+"""),
420+#----------------------------------------------------------------------------
421+('Open Tickets by Version',
422+"""
423+ * List all not closed tickets by priority.
424+ * Group results by version.
425+""",
426+"""
427+SELECT p.value AS __color__,
428+   (CASE WHEN IFNULL(version, '') = '' THEN 'Not Specified' ELSE 'Version ' || version END) AS __group__,
429+   id AS ticket, summary, component, status, milestone, severity,
430+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
431+   time AS created,
432+   changetime AS _changetime, description AS _description,
433+   reporter AS _reporter
434+  FROM ticket t, enum p
435+  WHERE status <> 'closed'
436+AND p.name = t.priority AND p.type = 'priority'
437+  ORDER BY (IFNULL(version, '') = '') desc,version, p.value, severity, time
438+"""),
439+#----------------------------------------------------------------------------
440+('Open Tickets by Milestone',
441+"""
442+ * List all not closed tickets by priority.
443+ * Group results by milestone.
444+""",
445+"""
446+SELECT p.value AS __color__,
447+   (CASE WHEN IFNULL(milestone, '') = '' THEN 'Not Assigned' ELSE milestone||' Release' END) AS __group__,
448+   id AS ticket, summary, component, status, version, severity,
449+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
450+   time AS created,
451+   changetime AS _changetime, description AS _description,
452+   reporter AS _reporter
453+  FROM ticket t, enum p
454+  WHERE status <> 'closed'
455+AND p.name = t.priority AND p.type = 'priority'
456+  ORDER BY (IFNULL(milestone, '') = '') DESC,milestone, p.value, severity, time
457+"""),
458+#----------------------------------------------------------------------------
459+('Open Tickets by Owner',
460+"""
461+List not closed tickets, group by ticket owner, sorted by priority.
462+""",
463+"""
464+SELECT p.value AS __color__,
465+   (CASE WHEN IFNULL(owner, '') = '' THEN 'Not Assigned' ELSE owner END) AS __group__,
466+   id AS ticket, summary, component, status, version, milestone, severity,
467+   time AS created, changetime AS _changetime, description AS _description,
468+   reporter AS _reporter
469+  FROM ticket t,enum p
470+  WHERE status <> 'closed'
471+AND p.name=t.priority AND p.type='priority'
472+  ORDER BY (IFNULL(owner, '') = '') DESC, owner, p.value, severity, time
473+"""),
474+#----------------------------------------------------------------------------
475+('Open Tickets by Status',
476+"""
477+ * List all not closed tickets by priority.
478+ * Group results by status.
479+""",
480+"""
481+SELECT p.value AS __color__,
482+   status AS __group__,
483+   id AS ticket, summary, component, version, milestone, severity, owner,
484+   time AS created,
485+   changetime AS _changetime, description AS _description,
486+   reporter AS _reporter
487+  FROM ticket t, enum q, enum p
488+  WHERE status <> 'closed'
489+AND q.name = t.status AND q.type = 'status'
490+AND p.name = t.priority AND p.type = 'priority'
491+  ORDER BY q.value, p.value, severity, time
492+"""),
493+#----------------------------------------------------------------------------
494+('Resolved Tickets, Mine first',
495+"""
496+ * List all resolved tickets by priority.
497+ * Show all tickets owned by the logged in user in a group first.
498+""",
499+"""
500+SELECT p.value AS __color__,
501+   (CASE owner
502+     WHEN '$USER' THEN 'My Tickets'
503+     ELSE 'Active Tickets'
504+    END) AS __group__,
505+   id AS ticket, summary, component, version, milestone, severity,
506+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
507+   time AS created,
508+   changetime AS _changetime, description AS _description,
509+   reporter AS _reporter
510+  FROM ticket t, enum p
511+  WHERE status = 'resolved'
512+AND p.name = t.priority AND p.type = 'priority'
513+  ORDER BY (owner = '$USER') DESC, p.value, milestone, severity, time
514+"""),
515+#----------------------------------------------------------------------------
516+('Resolved Tickets by Milestone',
517+"""
518+List resolved tickets, sorted by priority, grouped by milestone
519+""",
520+"""
521+SELECT p.value AS __color__,
522+   (CASE WHEN IFNULL(milestone, '') = '' THEN 'Not Assigned' ELSE milestone||' Release' END) AS __group__,
523+   id AS ticket, summary, component, version, severity,
524+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
525+   time AS created,
526+   changetime AS _changetime, description AS _description,
527+   reporter AS _reporter
528+  FROM ticket t, enum p
529+  WHERE status = 'resolved'
530+AND p.name = t.priority AND p.type = 'priority'
531+  ORDER BY (IFNULL(milestone, '') = '') DESC,milestone, p.value, severity, time
532+"""),
533+#----------------------------------------------------------------------------
534+('Resolved Tickets by Owner',
535+"""
536+List resolved tickets, group by ticket owner, sorted by priority.
537+""",
538+"""
539+SELECT p.value AS __color__,
540+   (CASE WHEN IFNULL(owner, '') = '' THEN 'Not Assigned' ELSE owner END) AS __group__,
541+   id AS ticket, summary, component, version, milestone, severity, time AS created,
542+   changetime AS _changetime, description AS _description,
543+   reporter AS _reporter
544+  FROM ticket t,enum p
545+  WHERE status = 'resolved'
546+AND p.name=t.priority AND p.type='priority'
547+  ORDER BY (IFNULL(owner, '') = '') DESC, owner, p.value, severity, time
548+"""),
549+#----------------------------------------------------------------------------
550+('Completed Tickets by Milestone (Full Description)',
551+"""
552+Release Notes: List verified and closed tickets, group by milestone, include description.
553+""",
554+"""
555+SELECT p.value AS __color__,
556+   (CASE WHEN IFNULL(milestone, '') = '' THEN 'Not Assigned' ELSE milestone||' Release' END) AS __group__,
557+   id AS ticket, summary, component, status, version, severity, time AS created,
558+   description AS _description_,
559+   changetime AS _changetime, reporter AS _reporter
560+  FROM ticket t, enum p
561+  WHERE status IN ('verified', 'closed')
562+AND p.name = t.priority AND p.type = 'priority'
563+  ORDER BY (IFNULL(milestone, '') = '') DESC,milestone, p.value, severity, time
564 """))
565 
566 
567@@ -347,9 +515,9 @@
568 
569 # (table, (column1, column2), ((row1col1, row1col2), (row2col1, row2col2)))
570 data = (('component',
571-             ('name', 'owner'),
572-               (('component1', 'somebody'),
573-                ('component2', 'somebody'))),
574+             ('name', 'owner', 'qaowner'),
575+               (('component1', 'somebody', 'qasomebody'),
576+                ('component2', 'somebody', 'qasomebody'))),
577            ('milestone',
578              ('name', 'time'),
579                (('', 0),
580@@ -367,7 +535,9 @@
581                (('status', 'new', 1),
582                 ('status', 'assigned', 2),
583                 ('status', 'reopened', 3),
584-                ('status', 'closed', 4),
585+                ('status', 'resolved', 4),
586+                ('status', 'verified', 5),
587+                ('status', 'closed', 6),
588                 ('resolution', 'fixed', 1),
589                 ('resolution', 'invalid', 2),
590                 ('resolution', 'wontfix', 3),
591@@ -426,6 +596,7 @@
592   ('project', 'footer',
593    ' Visit the Trac open source project at<br />'
594    '<a href="http://trac.edgewall.com/">http://trac.edgewall.com/</a>'),
595+  ('ticket', 'workflow', 'trac.workflows.QaRmtWorkflow'),
596   ('ticket', 'default_version', ''),
597   ('ticket', 'default_severity', 'normal'),
598   ('ticket', 'default_priority', 'normal'),
599Index: trac/Milestone.py
600===================================================================
601--- trac/Milestone.py   (revision 1098)
602+++ trac/Milestone.py   (working copy)
603@@ -60,10 +60,10 @@
604     if not group:
605         queries['all_tickets'] = env.href.query({'milestone': milestone})
606         queries['active_tickets'] = env.href.query({
607-            'milestone': milestone, 'status': ['new', 'assigned', 'reopened']
608+            'milestone': milestone, 'status': ['new', 'assigned', 'reopened', 'resolved']
609         })
610         queries['closed_tickets'] = env.href.query({
611-            'milestone': milestone, 'status': 'closed'
612+            'milestone': milestone, 'status': ['closed', 'verified']
613         })
614     else:
615         queries['all_tickets'] = env.href.query({
616@@ -71,17 +71,17 @@
617         })
618         queries['active_tickets'] = env.href.query({
619             'milestone': milestone, grouped_by: group,
620-            'status': ['new', 'assigned', 'reopened']
621+            'status': ['new', 'assigned', 'reopened', 'resolved']
622         })
623         queries['closed_tickets'] = env.href.query({
624             'milestone': milestone, grouped_by: group,
625-            'status': 'closed'
626+            'status': ['closed', 'verified']
627         })
628     return queries
629 
630 def calc_ticket_stats(tickets):
631     total_cnt = len(tickets)
632-    active = [ticket for ticket in tickets if ticket['status'] != 'closed']
633+    active = [ticket for ticket in tickets if ticket['status'] != 'closed' and ticket['status'] != 'verified']
634     active_cnt = len(active)
635     closed_cnt = total_cnt - active_cnt
636 
637@@ -116,10 +116,11 @@
638                 if datestr:
639                     date = self.parse_date(datestr)
640             descr = self.args.get('descr', '')
641+            owner = self.args.get('owner', '')
642             if not id:
643-                self.create_milestone(name, date, descr)
644+                self.create_milestone(name, date, descr, owner)
645             else:
646-                self.update_milestone(id, name, date, descr)
647+                self.update_milestone(id, name, date, descr, owner)
648         elif id:
649             self.req.redirect(self.env.href.milestone(id))
650         else:
651@@ -141,15 +142,15 @@
652                             'Invalid Date Format')
653         return seconds
654 
655-    def create_milestone(self, name, date=0, descr=''):
656+    def create_milestone(self, name, date=0, descr='', owner=''):
657         self.perm.assert_permission(perm.MILESTONE_CREATE)
658         if not name:
659             raise TracError('You must provide a name for the milestone.',
660                             'Required Field Missing')
661         cursor = self.db.cursor()
662         self.log.debug("Creating new milestone '%s'" % name)
663-        cursor.execute("INSERT INTO milestone (id, name, time, descr) "
664-                       "VALUES (NULL, %s, %d, %s)", name, date, descr)
665+        cursor.execute("INSERT INTO milestone (id, name, time, descr, owner) "
666+                       "VALUES (NULL, %s, %d, %s, %s)", name, date, descr, owner)
667         self.db.commit()
668         self.req.redirect(self.env.href.milestone(name))
669 
670@@ -178,7 +179,7 @@
671         else:
672             self.req.redirect(self.env.href.milestone(id))
673 
674-    def update_milestone(self, id, name, date, descr):
675+    def update_milestone(self, id, name, date, descr, owner):
676         self.perm.assert_permission(perm.MILESTONE_MODIFY)
677         cursor = self.db.cursor()
678         self.log.info("Updating milestone '%s'" % id)
679@@ -188,8 +189,8 @@
680             cursor.execute('UPDATE ticket SET milestone = %s '
681                             'WHERE milestone = %s', name, id)
682             cursor.execute("UPDATE milestone SET name = %s, time = %d, "
683-                           "descr = %s WHERE name = %s",
684-                           name, date, descr, id)
685+                           "descr = %s, owner = %s WHERE name = %s",
686+                           name, date, descr, owner, id)
687             self.db.commit()
688             self.req.redirect(self.env.href.milestone(name))
689         else:
690@@ -222,7 +223,7 @@
691 
692     def get_milestone(self, name):
693         cursor = self.db.cursor()
694-        cursor.execute("SELECT name, time, descr FROM milestone "
695+        cursor.execute("SELECT name, time, descr, owner FROM milestone "
696                        "WHERE name = %s ORDER BY time, name", name)
697         row = cursor.fetchone()
698         cursor.close()
699@@ -237,6 +238,7 @@
700         t = row['time'] and int(row['time'])
701         if t > 0:
702             milestone['date'] = time.strftime('%x', time.localtime(t))
703+        milestone['owner'] = row['owner'] or ''
704         return milestone
705 
706     def render(self):
707Index: trac/Timeline.py
708===================================================================
709--- trac/Timeline.py    (revision 1098)
710+++ trac/Timeline.py    (working copy)
711@@ -52,6 +52,9 @@
712         REOPENED_TICKET = 4
713         WIKI = 5
714         MILESTONE = 6
715+        VERIFIED_TICKET = 7
716+        RESOLVED_TICKET = 8
717+        RETESTED_TICKET = 9
718 
719         q = []
720         if changeset:
721@@ -60,26 +63,65 @@
722                      "FROM revision WHERE time>=%s AND time<=%s" %
723                      (start, stop))
724         if tickets:
725+            # New tickets
726             q.append("SELECT time, id AS idata, '' AS tdata, 2 AS type, "
727                      "summary AS message, reporter AS author "
728                      "FROM ticket WHERE time>=%s AND time<=%s" %
729                      (start, stop))
730-            q.append("SELECT time, ticket AS idata, '' AS tdata, 4 AS type, "
731-                     "'' AS message, author "
732-                     "FROM ticket_change WHERE field='status' "
733-                     "AND newvalue='reopened' AND time>=%s AND time<=%s" %
734-                     (start, stop))
735+            # Reopened tickets
736             q.append("SELECT t1.time AS time, t1.ticket AS idata,"
737+                     "       '' AS tdata, 4 AS type,"
738+                     "       t3.newvalue AS message, t1.author AS author"
739+                     " FROM ticket_change t1"
740+                     "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
741+                     "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
742+                     " WHERE t1.field = 'status' AND t1.newvalue = 'reopened'"
743+                     "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
744+            # Closed tickets (including resolution field for old workflow)
745+            q.append("SELECT t1.time AS time, t1.ticket AS idata,"
746                      "       t2.newvalue AS tdata, 3 AS type,"
747                      "       t3.newvalue AS message, t1.author AS author"
748                      " FROM ticket_change t1"
749-                     "   INNER JOIN ticket_change t2 ON t1.ticket = t2.ticket"
750-                     "     AND t1.time = t2.time"
751+                     "   LEFT OUTER JOIN ticket_change t2 ON t1.ticket = t2.ticket"
752+                     "     AND t1.time = t2.time AND t2.field = 'resolution'"
753                      "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
754                      "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
755                      " WHERE t1.field = 'status' AND t1.newvalue = 'closed'"
756-                     "   AND t2.field = 'resolution'"
757                      "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
758+            # Verified tickets (including resolution field for customized workflows)
759+            q.append("SELECT t1.time AS time, t1.ticket AS idata,"
760+                     "       t2.newvalue AS tdata, 7 AS type,"
761+                     "       t3.newvalue AS message, t1.author AS author"
762+                     " FROM ticket_change t1"
763+                     "   LEFT OUTER JOIN ticket_change t2 ON t1.ticket = t2.ticket"
764+                     "     AND t1.time = t2.time AND t2.field = 'resolution'"
765+                     "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
766+                     "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
767+                     " WHERE t1.field = 'status' AND t1.newvalue = 'verified'"
768+                     "   AND t1.oldvalue<>'closed'"
769+                     "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
770+            # Resolved tickets (including resolution field)
771+            q.append("SELECT t1.time AS time, t1.ticket AS idata,"
772+                     "       t2.newvalue AS tdata, 8 AS type,"
773+                     "       t3.newvalue AS message, t1.author AS author"
774+                     " FROM ticket_change t1"
775+                     "   LEFT OUTER JOIN ticket_change t2 ON t1.ticket = t2.ticket"
776+                     "     AND t1.time = t2.time AND t2.field = 'resolution'"
777+                     "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
778+                     "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
779+                     " WHERE t1.field = 'status' AND t1.newvalue = 'resolved'"
780+                     "   AND t1.oldvalue NOT IN ('verified', 'closed')"
781+                     "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
782+            # Retested tickets
783+            q.append("SELECT t1.time AS time, t1.ticket AS idata,"
784+                     "       '' AS tdata, 9 AS type,"
785+                     "       t3.newvalue AS message, t1.author AS author"
786+                     " FROM ticket_change t1"
787+                     "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
788+                     "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
789+                     " WHERE t1.field = 'status' AND t1.newvalue = 'resolved'"
790+                     "   AND t1.oldvalue IN ('verified', 'closed')"
791+                     "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
792         if wiki:
793             q.append("SELECT time, -1 AS idata, name AS tdata, 5 AS type, "
794                      "comment AS message, author "
795@@ -87,7 +129,7 @@
796                      (start, stop))
797         if milestone:
798             q.append("SELECT time, -1 AS idata, '' AS tdata, 6 AS type, "
799-                     "name AS message, '' AS author "
800+                     "name AS message, owner AS author "
801                      "FROM milestone WHERE time>=%s AND time<=%s" %
802                      (start, stop))
803 
804@@ -110,7 +152,7 @@
805                     'date': time.strftime('%x', t),
806                     'datetime': time.strftime('%a, %d %b %Y %H:%M:%S GMT', gmt),
807                     'idata': int(row['idata']),
808-                    'tdata': row['tdata'],
809+                    'tdata': row['tdata'] or '',
810                     'type': int(row['type']),
811                     'message': row['message'] or '',
812                     'author': util.escape(row['author'] or 'anonymous')
813Index: trac/workflows/Base.py
814===================================================================
815--- trac/workflows/Base.py      (revision 0)
816+++ trac/workflows/Base.py      (revision 0)
817@@ -0,0 +1,87 @@
818+# -*- coding: iso8859-1 -*-
819+#
820+# Copyright (C) 2003, 2004 Edgewall Software
821+# Copyright (C) 2003, 2004 Jonas Borgström <jonas@edgewall.com>
822+#
823+# Trac is free software; you can redistribute it and/or
824+# modify it under the terms of the GNU General Public License as
825+# published by the Free Software Foundation; either version 2 of the
826+# License, or (at your option) any later version.
827+#
828+# Trac is distributed in the hope that it will be useful,
829+# but WITHOUT ANY WARRANTY; without even the implied warranty of
830+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
831+# General Public License for more details.
832+#
833+# You should have received a copy of the GNU General Public License
834+# along with this program; if not, write to the Free Software
835+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
836+#
837+# Author: Pavel Kourochka <pkou@ua.fm>
838+#
839+# Abstract workflow definition
840+
841+class WorkflowBase:
842+    """
843+    Generic workflow class for Trac.
844+    """
845+
846+    def __init__(self, env, db, user):
847+        """
848+        Constructor for workflow class.
849+        """
850+        self.env = env
851+        self.db = db
852+        self.user = user
853+
854+    def get_actions(self, ticket):
855+        """
856+        For existing tickets only.
857+        Return the list of available actions for specified ticket.
858+        """
859+        raise Exception, "WorkflowBase::get_actions not implemented"
860+
861+    def do_action(self, ticket, action, args):
862+        """
863+        For new and existing tickets.
864+        Perform action on a ticket.  For new tickets, action name is 'create'.
865+        """
866+        raise Exception, "WorkflowBase::do_action not implemented"
867+
868+    def get_actions_template(self, ticket):
869+        """
870+        For new and existing tickets.
871+        Return the name of ClearSilver template file for the workflow.
872+        Return None if no additional template is required.
873+        """
874+        return None
875+
876+    def init_template(self, ticket, hdf):
877+        """
878+        For new and existing tickets.
879+        Initialize ClearSilver variables for actions template.
880+        Called if get_actions_template() returns file name only.
881+        """
882+        pass
883+
884+    def validate(self, ticket):
885+        """
886+        For new and existing tickets.
887+        Validate ticket.
888+        Return list of Wiki strings that describe errors in the ticket.
889+        """
890+        return []
891+
892+    def on_insert(self, ticket):
893+        """
894+        For new tickets only.
895+        Update ticket fields just before inserting the ticket into database.
896+        """
897+        pass
898+
899+    def on_update(self, ticket):
900+        """
901+        For existing tickets only.
902+        Update ticket fields just before saving the ticket into database.
903+        """
904+        pass
905Index: trac/workflows/QaRmtWorkflow.py
906===================================================================
907--- trac/workflows/QaRmtWorkflow.py     (revision 0)
908+++ trac/workflows/QaRmtWorkflow.py     (revision 0)
909@@ -0,0 +1,132 @@
910+# -*- coding: iso8859-1 -*-
911+#
912+# Copyright (C) 2003, 2004 Edgewall Software
913+# Copyright (C) 2003, 2004 Jonas Borgström <jonas@edgewall.com>
914+#
915+# Trac is free software; you can redistribute it and/or
916+# modify it under the terms of the GNU General Public License as
917+# published by the Free Software Foundation; either version 2 of the
918+# License, or (at your option) any later version.
919+#
920+# Trac is distributed in the hope that it will be useful,
921+# but WITHOUT ANY WARRANTY; without even the implied warranty of
922+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
923+# General Public License for more details.
924+#
925+# You should have received a copy of the GNU General Public License
926+# along with this program; if not, write to the Free Software
927+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
928+#
929+# Author: Pavel Kourochka <pkou@ua.fm>
930+#
931+# Workflow definition for development, QA, and release management teams
932+
933+from trac.workflows.SimpleWorkflow import SimpleWorkflow
934+
935+class QaRmtWorkflow(SimpleWorkflow):
936+
937+    def get_actions(self, ticket):
938+        actions = {
939+ 'new':      ['leave','reassign','resolve',                          'accept'],
940+ 'assigned': ['leave','reassign','resolve',                                  ],
941+ 'reopened': ['leave','reassign','resolve',                                  ],
942+ 'resolved': ['leave','reassign',          'reopen',         'close','verify'],
943+ 'verified': ['leave','reassign',          'reopen','retest','close'         ],
944+ 'closed':   ['leave',                     'reopen','retest'                 ]
945+        }
946+        return actions.get(ticket['status'], ['leave'])
947+
948+    def do_action(self, ticket, action, args):
949+        if action == 'accept':
950+            ticket['status'] = 'assigned'
951+            ticket['owner'] = self.user
952+        elif action == 'resolve':
953+            ticket['status'] = 'resolved'
954+            ticket['resolution'] = args.get('resolve_resolution')
955+            ticket['owner'] = ''
956+        elif action == 'verify':
957+            ticket['status'] = 'verified'
958+            ticket['owner'] = ''
959+        elif action == 'close':
960+            ticket['status'] = 'closed'
961+        elif action == 'reassign':
962+            newowner = args.get('reassign_owner')
963+            if ticket['owner'] != newowner:
964+                if ticket['status'] == 'assigned': ticket['status'] = 'new'
965+                ticket['owner'] = newowner
966+        elif action == 'reopen':
967+            ticket['status'] = 'reopened'
968+            ticket['resolution'] = ''
969+            ticket['owner'] = ''
970+        elif action == 'retest':
971+            ticket['status'] = 'resolved'
972+            ticket['owner'] = ''
973+
974+    def get_actions_template(self, ticket):
975+        if ticket.has_key('id'):
976+            return 'ticket_workflow_qarmt.cs'
977+        else:
978+            return None
979+
980+    def on_insert(self, ticket):
981+        SimpleWorkflow.on_insert(self, ticket)
982+
983+        # The owner field defaults to the milestone owner if
984+        # the component does not have any owner
985+        cursor = self.db.cursor()
986+        if ticket.get('owner', '') == '':
987+            cursor.execute('SELECT owner FROM milestone '
988+                           'WHERE name=%s', ticket.get('milestone', ''))
989+            ticket['owner'] = cursor.fetchone()[0] or ''
990+
991+    def on_update(self, ticket):
992+        SimpleWorkflow.on_update(self, ticket)
993+        if not ticket._old: return # Not modified
994+
995+        cursor = self.db.cursor()
996+        status = ticket.get('status', 'new')
997+        component = ticket.get('component', '')
998+        milestone = ticket.get('milestone', '')
999+
1000+        # If the milestone is changed on a 'new' ticket then owner field
1001+        # is updated accordingly if the component does not have any owner.
1002+        # (related to #623).
1003+        if status == 'new' and ticket._old.has_key('milestone') and \
1004+               not ticket._old.has_key('component') and \
1005+               not ticket._old.has_key('owner'):
1006+            cursor.execute('SELECT owner FROM component '
1007+                           'WHERE name=%s', component)
1008+            if not cursor.fetchone()[0]:
1009+                cursor.execute('SELECT owner FROM milestone '
1010+                               'WHERE name=%s', ticket._old['milestone'])
1011+                old_owner = cursor.fetchone()[0]
1012+                if ticket['owner'] == old_owner:
1013+                    cursor.execute('SELECT owner FROM milestone '
1014+                                   'WHERE name=%s', milestone)
1015+                    ticket['owner'] = cursor.fetchone()[0] or ''
1016+
1017+        # 1. The owner field defaults to the component owner for active tickets
1018+        if ticket.get('owner', '') == '' and status in ['new', 'reopened']:
1019+            cursor.execute('SELECT owner FROM component '
1020+                           'WHERE name=%s', component)
1021+            newowner = cursor.fetchone()[0]
1022+            if newowner: ticket['owner'] = newowner
1023+
1024+        # 2. The owner field defaults to component QA owner for testing tickets
1025+        if ticket.get('owner', '') == '' and status == 'resolved':
1026+            cursor.execute('SELECT qaowner FROM component '
1027+                           'WHERE name=%s', component)
1028+            newowner = cursor.fetchone()[0]
1029+            if newowner: ticket['owner'] = newowner
1030+
1031+        # 3. The owner field defaults to milestone owner for open tickets
1032+        if ticket.get('owner', '') == '' and status != 'closed':
1033+            cursor.execute('SELECT owner FROM milestone '
1034+                           'WHERE name=%s', milestone)
1035+            newowner = cursor.fetchone()[0]
1036+            if newowner: ticket['owner'] = newowner
1037+
1038+        # 4. The owner field defaults to reporter for verified tickets
1039+        if ticket.get('owner', '') == '' and status == 'verified':
1040+            reporter = ticket.get('reporter', '')
1041+            if reporter: ticket['owner'] = reporter
1042Index: trac/workflows/__init__.py
1043===================================================================
1044--- trac/workflows/__init__.py  (revision 0)
1045+++ trac/workflows/__init__.py  (revision 0)
1046@@ -0,0 +1 @@
1047+__all__ = ['Base', 'SimpleWorkflow', 'QaRmtWorkflow']
1048Index: trac/workflows/SimpleWorkflow.py
1049===================================================================
1050--- trac/workflows/SimpleWorkflow.py    (revision 0)
1051+++ trac/workflows/SimpleWorkflow.py    (revision 0)
1052@@ -0,0 +1,94 @@
1053+# -*- coding: iso8859-1 -*-
1054+#
1055+# Copyright (C) 2003, 2004 Edgewall Software
1056+# Copyright (C) 2003, 2004 Jonas Borgström <jonas@edgewall.com>
1057+#
1058+# Trac is free software; you can redistribute it and/or
1059+# modify it under the terms of the GNU General Public License as
1060+# published by the Free Software Foundation; either version 2 of the
1061+# License, or (at your option) any later version.
1062+#
1063+# Trac is distributed in the hope that it will be useful,
1064+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1065+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
1066+# General Public License for more details.
1067+#
1068+# You should have received a copy of the GNU General Public License
1069+# along with this program; if not, write to the Free Software
1070+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
1071+#
1072+# Author: Pavel Kourochka <pkou@ua.fm>
1073+#
1074+# Simple workflow definition (as in Trac 0.8)
1075+
1076+from trac.workflows.Base import WorkflowBase
1077+
1078+class SimpleWorkflow(WorkflowBase):
1079+
1080+    def get_actions(self, ticket):
1081+        actions = {
1082+            'new':      ['leave', 'resolve', 'reassign', 'accept'],
1083+            'assigned': ['leave', 'resolve', 'reassign'          ],
1084+            'reopened': ['leave', 'resolve', 'reassign'          ],
1085+            'closed':   ['leave',                        'reopen']
1086+        }
1087+        return actions.get(ticket['status'], ['leave'])
1088+
1089+    def do_action(self, ticket, action, args):
1090+        if action == 'accept':
1091+            ticket['status'] = 'assigned'
1092+            ticket['owner'] = self.user
1093+        elif action == 'resolve':
1094+            ticket['status'] = 'closed'
1095+            ticket['resolution'] = args.get('resolve_resolution')
1096+        elif action == 'reassign':
1097+            ticket['owner'] = args.get('reassign_owner')
1098+            ticket['status'] = 'new'
1099+        elif action == 'reopen':
1100+            ticket['status'] = 'reopened'
1101+            ticket['resolution'] = ''
1102+
1103+    def get_actions_template(self, ticket):
1104+        if ticket.has_key('id'):
1105+            return 'ticket_workflow_simple.cs'
1106+        else:
1107+            return None
1108+
1109+    def init_template(self, ticket, hdf):
1110+        WorkflowBase.init_template(self, ticket, hdf)
1111+        if ticket.has_key('id'):
1112+            for a in self.get_actions(ticket):
1113+                hdf.setValue('ticket.workflow.action.' + a, '1')
1114+
1115+    def validate(self, ticket):
1116+        err = WorkflowBase.validate(self, ticket)
1117+        if not ticket.get('summary'):
1118+            err.append("The ticket must contain '''Summary''' field.")
1119+        return err
1120+
1121+    def on_insert(self, ticket):
1122+        WorkflowBase.on_insert(self, ticket)
1123+
1124+        # The owner field defaults to the component owner
1125+        cursor = self.db.cursor()
1126+        if ticket.get('owner', '') == '':
1127+            cursor.execute('SELECT owner FROM component '
1128+                           'WHERE name=%s', ticket.get('component', ''))
1129+            ticket['owner'] = cursor.fetchone()[0] or ''
1130+
1131+    def on_update(self, ticket):
1132+        WorkflowBase.on_update(self, ticket)
1133+        if not ticket._old: return # Not modified
1134+
1135+        # If the component is changed on a 'new' ticket then owner field
1136+        # is updated accordingly. (#623).
1137+        cursor = self.db.cursor()
1138+        if ticket['status'] == 'new' and ticket._old.has_key('component') and \
1139+               not ticket._old.has_key('owner'):
1140+            cursor.execute('SELECT owner FROM component '
1141+                           'WHERE name=%s', ticket._old['component'])
1142+            old_owner = cursor.fetchone()[0]
1143+            if ticket['owner'] == old_owner:
1144+                cursor.execute('SELECT owner FROM component '
1145+                               'WHERE name=%s', ticket['component'])
1146+                ticket['owner'] = cursor.fetchone()[0] or ''
1147Index: trac/upgrades/db8.py
1148===================================================================
1149--- trac/upgrades/db8.py        (revision 0)
1150+++ trac/upgrades/db8.py        (revision 0)
1151@@ -0,0 +1,275 @@
1152+sql = """
1153+-- Add statuses 'resolved' and 'verified'
1154+UPDATE enum SET value = 6 WHERE type = 'status' AND name = 'closed';
1155+INSERT INTO enum (type, name, value) VALUES ('status', 'resolved', 4);
1156+INSERT INTO enum (type, name, value) VALUES ('status', 'verified', 5);
1157+
1158+-- Add QA Contact to 'component'
1159+CREATE TEMPORARY TABLE component_backup AS SELECT * FROM component;
1160+DROP TABLE component;
1161+CREATE TABLE component (
1162+         name            text PRIMARY KEY,
1163+         owner           text,
1164+         qaowner         text
1165+);
1166+INSERT INTO component SELECT name, owner, owner AS qaowner FROM component_backup;
1167+DROP TABLE component_backup;
1168+
1169+-- Add Release Manager Contact to 'milestone'
1170+CREATE TEMPORARY TABLE milestone_backup AS SELECT * FROM milestone;
1171+DROP TABLE milestone;
1172+CREATE TABLE milestone (
1173+         id              integer PRIMARY KEY,
1174+         name            text,
1175+         time            integer,
1176+         owner           text,
1177+         descr           text,
1178+         UNIQUE(name)
1179+);
1180+INSERT INTO milestone SELECT id, name, time, '' AS owner, descr FROM milestone_backup;
1181+DROP TABLE milestone_backup;
1182+
1183+-- Modify 'All Tickets by Version' report
1184+UPDATE report SET sql = '
1185+SELECT p.value AS __color__,
1186+   (CASE WHEN IFNULL(version, '''') = '''' THEN ''Not Specified'' ELSE ''Version '' || version END) AS __group__,
1187+   id AS ticket, summary, component, milestone, severity,
1188+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
1189+   time AS created,
1190+   changetime AS _changetime, description AS _description,
1191+   reporter AS _reporter
1192+  FROM ticket t, enum p
1193+  WHERE status IN (''new'', ''assigned'', ''reopened'')
1194+AND p.name = t.priority AND p.type = ''priority''
1195+  ORDER BY (IFNULL(version, '''') = '''') DESC,version, p.value, severity, time
1196+' WHERE title = 'Active Tickets by Version';
1197+
1198+-- Modify 'All Tickets by Milestone' report
1199+UPDATE report SET sql = '
1200+SELECT p.value AS __color__,
1201+   (CASE WHEN IFNULL(milestone, '''') = '''' THEN ''Not Assigned'' ELSE milestone||'' Release'' END) AS __group__,
1202+   id AS ticket, summary, component, version, severity,
1203+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
1204+   time AS created,
1205+   changetime AS _changetime, description AS _description,
1206+   reporter AS _reporter
1207+  FROM ticket t, enum p
1208+  WHERE status IN (''new'', ''assigned'', ''reopened'')
1209+AND p.name = t.priority AND p.type = ''priority''
1210+  ORDER BY (IFNULL(milestone, '''') = '''') DESC,milestone, p.value, severity, time
1211+', title = 'Active Tickets by Milestone'
1212+WHERE title = 'All Tickets by Milestone';
1213+
1214+-- Modify 'Assigned, Active Tickets by Owner' report
1215+UPDATE report SET sql = '
1216+SELECT p.value AS __color__,
1217+   (CASE WHEN IFNULL(owner, '''') = '''' THEN ''Not Assigned'' ELSE owner END) AS __group__,
1218+   id AS ticket, summary, component, version, milestone, severity, time AS created,
1219+   changetime AS _changetime, description AS _description,
1220+   reporter AS _reporter
1221+  FROM ticket t,enum p
1222+  WHERE status = ''assigned''
1223+AND p.name=t.priority AND p.type=''priority''
1224+  ORDER BY (IFNULL(owner, '''') = '''') DESC, owner, p.value, severity, time
1225+' WHERE title = 'Assigned, Active Tickets by Owner';
1226+
1227+-- Modify 'Assigned, Active Tickets by Owner (Full Description)' report
1228+UPDATE report SET sql = '
1229+SELECT p.value AS __color__,
1230+   owner AS __group__,
1231+   id AS ticket, summary, component, version, milestone, severity, time AS created,
1232+   description AS _description_,
1233+   changetime AS _changetime, reporter AS _reporter
1234+  FROM ticket t, enum p
1235+  WHERE status = ''assigned''
1236+AND p.name = t.priority AND p.type = ''priority''
1237+  ORDER BY owner, p.value, severity, time
1238+' WHERE title = 'Assigned, Active Tickets by Owner (Full Description)';
1239+
1240+-- Modify 'All Tickets By Milestone  (Including closed)' report
1241+UPDATE report SET sql = '
1242+SELECT p.value AS __color__,
1243+   (CASE WHEN IFNULL(t.milestone, '''') = '''' THEN ''Not Assigned'' ELSE t.milestone || '' Release'' END) AS __group__,
1244+   (CASE status
1245+      WHEN ''closed'' THEN ''color: #777; background: #ddd; border-color: #ccc;''
1246+      ELSE
1247+        (CASE owner WHEN ''$USER'' THEN ''font-weight: bold'' END)
1248+    END) AS __style__,
1249+   id AS ticket, summary, component, status,
1250+   resolution,version, severity, priority, owner,
1251+   changetime AS modified,
1252+   time AS _time,reporter AS _reporter
1253+  FROM ticket t,enum p
1254+  WHERE p.name=t.priority AND p.type=''priority''
1255+  ORDER BY (IFNULL(milestone, '''') = '''') DESC, milestone DESC, (status = ''closed''),
1256+        (CASE status WHEN ''closed'' THEN modified ELSE -p.value END) DESC
1257+' WHERE title = 'All Tickets By Milestone  (Including closed)';
1258+
1259+-- Modify 'My Tickets' report
1260+UPDATE report SET sql = '
1261+SELECT p.value AS __color__,
1262+   (CASE status WHEN ''assigned'' THEN ''Assigned'' ELSE ''Owned'' END) AS __group__,
1263+   id AS ticket, summary, component, status, version, milestone,
1264+   severity, priority, time AS created,
1265+   changetime AS _changetime, description AS _description,
1266+   reporter AS _reporter
1267+  FROM ticket t, enum p
1268+  WHERE t.status <> ''closed''
1269+AND p.name = t.priority AND p.type = ''priority'' AND owner = ''$USER''
1270+  ORDER BY (status = ''assigned'') DESC, p.value, milestone, severity, time
1271+' WHERE title = 'My Tickets';
1272+
1273+-- New reports
1274+
1275+INSERT INTO report VALUES(NULL,NULL,'Open Tickets, Mine first','
1276+SELECT p.value AS __color__,
1277+   (CASE owner
1278+     WHEN ''$USER'' THEN ''My Tickets''
1279+     ELSE ''Open Tickets''
1280+    END) AS __group__,
1281+   id AS ticket, summary, component, status, version, milestone, severity,
1282+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
1283+   time AS created,
1284+   changetime AS _changetime, description AS _description,
1285+   reporter AS _reporter
1286+  FROM ticket t, enum p
1287+  WHERE status <> ''closed''
1288+AND p.name = t.priority AND p.type = ''priority''
1289+  ORDER BY (owner = ''$USER'') DESC, p.value, milestone, severity, time
1290+','
1291+ * List all not closed tickets by priority.
1292+ * Show all tickets owned by the logged in user in a group first.
1293+');
1294+
1295+INSERT INTO report VALUES(NULL,NULL,'Open Tickets by Version','
1296+SELECT p.value AS __color__,
1297+   (CASE WHEN IFNULL(version, '''') = '''' THEN ''Not Specified'' ELSE ''Version '' || version END) AS __group__,
1298+   id AS ticket, summary, component, status, milestone, severity,
1299+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
1300+   time AS created,
1301+   changetime AS _changetime, description AS _description,
1302+   reporter AS _reporter
1303+  FROM ticket t, enum p
1304+  WHERE status <> ''closed''
1305+AND p.name = t.priority AND p.type = ''priority''
1306+  ORDER BY (IFNULL(version, '''') = '''') desc,version, p.value, severity, time
1307+','
1308+ * List all not closed tickets by priority.
1309+ * Group results by version.
1310+');
1311+
1312+INSERT INTO report VALUES(NULL,NULL,'Open Tickets by Milestone','
1313+SELECT p.value AS __color__,
1314+   (CASE WHEN IFNULL(milestone, '''') = '''' THEN ''Not Assigned'' ELSE milestone||'' Release'' END) AS __group__,
1315+   id AS ticket, summary, component, status, version, severity,
1316+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
1317+   time AS created,
1318+   changetime AS _changetime, description AS _description,
1319+   reporter AS _reporter
1320+  FROM ticket t, enum p
1321+  WHERE status <> ''closed''
1322+AND p.name = t.priority AND p.type = ''priority''
1323+  ORDER BY (IFNULL(milestone, '''') = '''') DESC,milestone, p.value, severity, time
1324+','
1325+ * List all not closed tickets by priority.
1326+ * Group results by milestone.
1327+');
1328+
1329+INSERT INTO report VALUES(NULL,NULL,'Open Tickets by Owner','
1330+SELECT p.value AS __color__,
1331+   (CASE WHEN IFNULL(owner, '''') = '''' THEN ''Not Assigned'' ELSE owner END) AS __group__,
1332+   id AS ticket, summary, component, status, version, milestone, severity,
1333+   time AS created, changetime AS _changetime, description AS _description,
1334+   reporter AS _reporter
1335+  FROM ticket t,enum p
1336+  WHERE status <> ''closed''
1337+AND p.name=t.priority AND p.type=''priority''
1338+  ORDER BY (IFNULL(owner, '''') = '''') DESC, owner, p.value, severity, time
1339+','
1340+List not closed tickets, group by ticket owner, sorted by priority.
1341+');
1342+
1343+INSERT INTO report VALUES(NULL,NULL,'Open Tickets by Status','
1344+SELECT p.value AS __color__,
1345+   status AS __group__,
1346+   id AS ticket, summary, component, version, milestone, severity, owner,
1347+   time AS created,
1348+   changetime AS _changetime, description AS _description,
1349+   reporter AS _reporter
1350+  FROM ticket t, enum q, enum p
1351+  WHERE status <> ''closed''
1352+AND q.name = t.status AND q.type = ''status''
1353+AND p.name = t.priority AND p.type = ''priority''
1354+  ORDER BY q.value, p.value, severity, time
1355+','
1356+ * List all not closed tickets by priority.
1357+ * Group results by status.
1358+');
1359+
1360+INSERT INTO report VALUES(NULL,NULL,'Resolved Tickets, Mine first','
1361+SELECT p.value AS __color__,
1362+   (CASE owner
1363+     WHEN ''$USER'' THEN ''My Tickets''
1364+     ELSE ''Active Tickets''
1365+    END) AS __group__,
1366+   id AS ticket, summary, component, version, milestone, severity,
1367+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
1368+   time AS created,
1369+   changetime AS _changetime, description AS _description,
1370+   reporter AS _reporter
1371+  FROM ticket t, enum p
1372+  WHERE status = ''resolved''
1373+AND p.name = t.priority AND p.type = ''priority''
1374+  ORDER BY (owner = ''$USER'') DESC, p.value, milestone, severity, time
1375+','
1376+ * List all resolved tickets by priority.
1377+ * Show all tickets owned by the logged in user in a group first.
1378+');
1379+
1380+INSERT INTO report VALUES(NULL,NULL,'Resolved Tickets by Milestone','
1381+SELECT p.value AS __color__,
1382+   (CASE WHEN IFNULL(milestone, '''') = '''' THEN ''Not Assigned'' ELSE milestone||'' Release'' END) AS __group__,
1383+   id AS ticket, summary, component, version, severity,
1384+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
1385+   time AS created,
1386+   changetime AS _changetime, description AS _description,
1387+   reporter AS _reporter
1388+  FROM ticket t, enum p
1389+  WHERE status = ''resolved''
1390+AND p.name = t.priority AND p.type = ''priority''
1391+  ORDER BY (IFNULL(milestone, '''') = '''') DESC,milestone, p.value, severity, time
1392+','
1393+List resolved tickets, sorted by priority, grouped by milestone
1394+');
1395+
1396+INSERT INTO report VALUES(NULL,NULL,'Resolved Tickets by Owner','
1397+SELECT p.value AS __color__,
1398+   (CASE WHEN IFNULL(owner, '''') = '''' THEN ''Not Assigned'' ELSE owner END) AS __group__,
1399+   id AS ticket, summary, component, version, milestone, severity, time AS created,
1400+   changetime AS _changetime, description AS _description,
1401+   reporter AS _reporter
1402+  FROM ticket t,enum p
1403+  WHERE status = ''resolved''
1404+AND p.name=t.priority AND p.type=''priority''
1405+  ORDER BY (IFNULL(owner, '''') = '''') DESC, owner, p.value, severity, time
1406+','
1407+List resolved tickets, group by ticket owner, sorted by priority.
1408+');
1409+
1410+INSERT INTO report VALUES(NULL,NULL,'Completed Tickets by Milestone (Full Description)','
1411+SELECT p.value AS __color__,
1412+   (CASE WHEN IFNULL(milestone, '''') = '''' THEN ''Not Assigned'' ELSE milestone||'' Release'' END) AS __group__,
1413+   id AS ticket, summary, component, status, version, severity, time AS created,
1414+   description AS _description_,
1415+   changetime AS _changetime, reporter AS _reporter
1416+  FROM ticket t, enum p
1417+  WHERE status IN (''verified'', ''closed'')
1418+AND p.name = t.priority AND p.type = ''priority''
1419+  ORDER BY (IFNULL(milestone, '''') = '''') DESC,milestone, p.value, severity, time
1420+','
1421+Release Notes: List verified and closed tickets, group by milestone, include description.
1422+');
1423+"""
1424+
1425+def do_upgrade(env, ver, cursor):
1426+    cursor.execute(sql)
1427Index: trac/upgrades/__init__.py
1428===================================================================
1429--- trac/upgrades/__init__.py   (revision 1098)
1430+++ trac/upgrades/__init__.py   (working copy)
1431@@ -1 +1 @@
1432-__all__ = ['db2', 'db3', 'db4', 'db5', 'db6', 'db7']
1433+__all__ = ['db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8']
1434Index: trac/Roadmap.py
1435===================================================================
1436--- trac/Roadmap.py     (revision 1098)
1437+++ trac/Roadmap.py     (working copy)
1438@@ -48,14 +48,14 @@
1439             icalhref += '&show=all'
1440             self.req.hdf.setValue('roadmap.href.list',
1441                                    self.env.href.roadmap())
1442-            query = "SELECT name, time, descr FROM milestone " \
1443+            query = "SELECT name, time, descr, owner FROM milestone " \
1444                     "WHERE name != '' " \
1445                     "ORDER BY (IFNULL(time, 0) = 0) ASC, time ASC, name"
1446         else:
1447             self.req.hdf.setValue('roadmap.showall', '1')
1448             self.req.hdf.setValue('roadmap.href.list',
1449                                    self.env.href.roadmap('all'))
1450-            query = "SELECT name, time, descr FROM milestone " \
1451+            query = "SELECT name, time, descr, owner FROM milestone " \
1452                     "WHERE name != '' " \
1453                     "AND (time IS NULL OR time = 0 OR time > %d) " \
1454                     "ORDER BY (IFNULL(time, 0) = 0) ASC, time ASC, name" % time()
1455@@ -74,6 +74,7 @@
1456             milestone = {
1457                 'name': row['name'],
1458                 'href': self.env.href.milestone(row['name']),
1459+                'owner': row['owner'] or '',
1460                 'time': row['time'] and int(row['time'])
1461             }
1462             descr = row['descr']
1463@@ -113,7 +114,7 @@
1464             status = ticket['status']
1465             if status == 'new' or status == 'reopened' and not ticket['owner']:
1466                 return 'NEEDS-ACTION'
1467-            elif status == 'assigned' or status == 'reopened':
1468+            elif status != 'closed':
1469                 return 'IN-PROCESS'
1470             elif status == 'closed':
1471                 if ticket['resolution'] == 'fixed': return 'COMPLETED'
1472Index: trac/Ticket.py
1473===================================================================
1474--- trac/Ticket.py      (revision 1098)
1475+++ trac/Ticket.py      (working copy)
1476@@ -130,19 +130,6 @@
1477 
1478         if not self._old and not comment: return # Not modified
1479 
1480-        # If the component is changed on a 'new' ticket then owner field
1481-        # is updated accordingly. (#623).
1482-        if self['status'] == 'new' and self._old.has_key('component') and \
1483-               not self._old.has_key('owner'):
1484-            cursor.execute('SELECT owner FROM component '
1485-                           'WHERE name=%s', self._old['component'])
1486-            old_owner = cursor.fetchone()[0]
1487-            if self['owner'] == old_owner:
1488-                cursor.execute('SELECT owner FROM component '
1489-                               'WHERE name=%s', self['component'])
1490-                self['owner'] = cursor.fetchone()[0]
1491-           
1492-
1493         for name in self._old.keys():
1494             if name[:7] == 'custom_':
1495                 fname = name[7:]
1496@@ -264,25 +251,38 @@
1497         i += 1
1498 
1499 
1500+def get_workflow(env, db, user):
1501+#    from trac.workflows.Simple import SimpleWorkflow
1502+#    return SimpleWorkflow(env, db, user)
1503+    modulename = env.get_config('ticket', 'workflow', \
1504+                                'trac.workflows.SimpleWorkflow')
1505+    i = modulename.rfind('.')
1506+    if i == -1:
1507+        classname = modulename
1508+    else:
1509+        classname = modulename[i+1:]
1510+
1511+    module = __import__(modulename, globals(), locals(), [classname])
1512+    constructor = getattr(module, classname)
1513+    workflow = constructor(env, db, user)
1514+
1515+    from workflows.Base import WorkflowBase
1516+    if not isinstance(workflow, WorkflowBase):
1517+        raise EnvironmentError, "Workflow class %s from %s must be " \
1518+                                "descendant of class WorkflowBase from " \
1519+                                "trac.workflows.base" \
1520+                                % (classname, modulename)
1521+
1522+    return workflow
1523+
1524+
1525 class NewticketModule(Module):
1526     template_name = 'newticket.cs'
1527 
1528-    def create_ticket(self):
1529-        if not self.args.get('summary'):
1530-            raise util.TracError('Tickets must contain Summary.')
1531-
1532-        ticket = Ticket()
1533-        ticket.populate(self.args)
1534+    def create_ticket(self, ticket, workflow):
1535         ticket.setdefault('reporter',self.req.authname)
1536 
1537-        # The owner field defaults to the component owner
1538-        cursor = self.db.cursor()
1539-        if ticket.get('component') and ticket.get('owner', '') == '':
1540-            cursor.execute('SELECT owner FROM component '
1541-                           'WHERE name=%s', ticket['component'])
1542-            owner = cursor.fetchone()[0]
1543-            ticket['owner'] = owner
1544-
1545+        workflow.on_insert(ticket)
1546         tktid = ticket.insert(self.db)
1547 
1548         # Notify
1549@@ -294,11 +294,25 @@
1550     def render (self):
1551         self.perm.assert_permission(perm.TICKET_CREATE)
1552 
1553-        if self.args.has_key('create'):
1554-            self.create_ticket()
1555+        ticket = Ticket()
1556 
1557-        ticket = Ticket()
1558+        preview = self.args.has_key('preview')
1559+        do_create = self.args.has_key('create')
1560         ticket.populate(self.args)
1561+
1562+        workflow = get_workflow(self.env, self.db, self.req.authname)
1563+
1564+        # Validate the ticket
1565+        err = []
1566+        if preview or do_create:
1567+            err.extend(workflow.validate(ticket))
1568+        if len(err) != 0: preview = 1
1569+
1570+        # Create the ticket if not in preview mode
1571+        if not preview and do_create:
1572+            workflow.do_action(ticket, 'create', self.args)
1573+            self.create_ticket(ticket, workflow)
1574+
1575         ticket.setdefault('component',
1576                           self.env.get_config('ticket', 'default_component'))
1577         ticket.setdefault('milestone',
1578@@ -320,6 +334,14 @@
1579         evals = util.mydict(zip(ticket.keys(),
1580                                 map(lambda x: util.escape(x), ticket.values())))
1581         util.add_to_hdf(evals, self.req.hdf, 'newticket')
1582+        if len(err) != 0:
1583+            self.req.hdf.setValue('newticket.workflow.error',
1584+                              wiki_to_html(' * ' + '\n * '.join(err),
1585+                                           self.req.hdf, self.env, self.db))
1586+        tpl = workflow.get_actions_template(ticket)
1587+        if tpl:
1588+            self.req.hdf.setValue('newticket.workflow.template', tpl)
1589+            workflow.init_template(ticket, self.req.hdf)
1590 
1591         util.sql_to_hdf(self.db, 'SELECT name FROM component ORDER BY name',
1592                         self.req.hdf, 'newticket.components')
1593@@ -334,38 +356,18 @@
1594 class TicketModule (Module):
1595     template_name = 'ticket.cs'
1596 
1597-    def save_changes (self, id):
1598+    def save_changes (self, ticket, workflow):
1599         self.perm.assert_permission (perm.TICKET_MODIFY)
1600-        ticket = Ticket(self.db, id)
1601 
1602-        if not self.args.get('summary'):
1603-            raise util.TracError('Tickets must contain Summary.')
1604-
1605         if self.args.has_key('description'):
1606             self.perm.assert_permission (perm.TICKET_ADMIN)
1607 
1608         if self.args.has_key('reporter'):
1609             self.perm.assert_permission (perm.TICKET_ADMIN)
1610 
1611-        # TODO: this should not be hard-coded like this
1612-        action = self.args.get('action', None)
1613-        if action == 'accept':
1614-            ticket['status'] =  'assigned'
1615-            ticket['owner'] = self.req.authname
1616-        if action == 'resolve':
1617-            ticket['status'] = 'closed'
1618-            ticket['resolution'] = self.args.get('resolve_resolution')
1619-        elif action == 'reassign':
1620-            ticket['owner'] = self.args.get('reassign_owner')
1621-            ticket['status'] = 'new'
1622-        elif action == 'reopen':
1623-            ticket['status'] = 'reopened'
1624-            ticket['resolution'] = ''
1625-
1626-        ticket.populate(self.args)
1627-
1628         now = int(time.time())
1629 
1630+        workflow.on_update(ticket)
1631         ticket.save_changes(self.db,
1632                             self.args.get('author', self.req.authname),
1633                             self.args.get('comment'),
1634@@ -373,7 +375,7 @@
1635 
1636         tn = TicketNotifyEmail(self.env)
1637         tn.notify(ticket, newticket=0, modtime=now)
1638-        self.req.redirect(self.env.href.ticket(id))
1639+        self.req.redirect(self.env.href.ticket(ticket['id']))
1640 
1641     def insert_ticket_data(self, hdf, id, ticket, reporter_id):
1642         """Insert ticket data into the hdf"""
1643@@ -431,27 +433,39 @@
1644     def render (self):
1645         self.perm.assert_permission (perm.TICKET_VIEW)
1646 
1647-        action = self.args.get('action', 'view')
1648-        preview = self.args.has_key('preview')
1649-
1650         if not self.args.has_key('id'):
1651             self.req.redirect(self.env.href.wiki())
1652 
1653         id = int(self.args.get('id'))
1654+        ticket = Ticket(self.db, id)
1655 
1656-        if not preview \
1657-               and action in ['leave', 'accept', 'reopen', 'resolve', 'reassign']:
1658-            self.save_changes (id)
1659+        action = self.args.get('action', None)
1660+        preview = self.args.has_key('preview')
1661+        if action or preview:
1662+            ticket.populate(self.args)
1663 
1664-        ticket = Ticket(self.db, id)
1665+        workflow = get_workflow(self.env, self.db, self.req.authname)
1666+
1667+        # Validate ticket
1668+        err = []
1669+        if action or preview:
1670+            actions = workflow.get_actions(ticket)
1671+            if action not in actions:
1672+                err.append("Invalid action '''%s''' is performed on the ticket. " \
1673+                           "Allowed actions are <''%s''>." % \
1674+                           (action, ', '.join(actions)))
1675+            err.extend(workflow.validate(ticket))
1676+        if len(err) != 0: preview = 1
1677+
1678+        # Save changes if not in preview mode
1679+        if not preview and action:
1680+            workflow.do_action(ticket, action, self.args)
1681+            self.save_changes(ticket, workflow)
1682+
1683         reporter_id = util.get_reporter_id(self.req)
1684 
1685         if preview:
1686-            # Use user supplied values
1687-            for field in Ticket.std_fields:
1688-                if self.args.has_key(field) and field != 'reporter':
1689-                    ticket[field] = self.args.get(field)
1690-            self.req.hdf.setValue('ticket.action', action)
1691+            if action: self.req.hdf.setValue('ticket.action', action)
1692             reporter_id = self.args.get('author')
1693             comment = self.args.get('comment')
1694             if comment:
1695@@ -462,6 +476,14 @@
1696                                                self.req.hdf, self.env, self.db))
1697 
1698         self.insert_ticket_data(self.req.hdf, id, ticket, reporter_id)
1699+        if len(err) != 0:
1700+            self.req.hdf.setValue('ticket.workflow.error',
1701+                              wiki_to_html(' * ' + '\n * '.join(err),
1702+                                           self.req.hdf, self.env, self.db))
1703+        tpl = workflow.get_actions_template(ticket)
1704+        if tpl:
1705+            self.req.hdf.setValue('ticket.workflow.template', tpl)
1706+            workflow.init_template(ticket, self.req.hdf)
1707 
1708         cursor = self.db.cursor()
1709         cursor.execute("SELECT max(id) FROM ticket")
1710Index: trac/WikiFormatter.py
1711===================================================================
1712--- trac/WikiFormatter.py       (revision 1098)
1713+++ trac/WikiFormatter.py       (working copy)
1714@@ -125,7 +125,7 @@
1715             elif row[1] == 'closed':
1716                 return '<a href="%s" title="CLOSED : %s"><del>#%d</del></a>' % (self._href.ticket(number), summary, number)
1717             else:
1718-                return '<a href="%s" title="%s">#%d</a>' % (self._href.ticket(number), summary, number)
1719+                return '<a href="%s" title="%s : %s">#%d</a>' % (self._href.ticket(number), row[1].upper(), summary, number)
1720 
1721     def _changesethref_formatter(self, match, fullmatch):
1722         number = int(match[1:-1])
1723@@ -158,7 +158,7 @@
1724                 elif row[1] == 'closed':
1725                     return self._href.ticket(args), '<del>%s:%s</del>' % (module, args), 0, 'CLOSED: ' + summary
1726                 else:
1727-                    return self._href.ticket(args), '%s:%s' % (module, args), 0, summary
1728+                    return self._href.ticket(args), '%s:%s' % (module, args), 0, row[1].upper() + ': ' + summary
1729             else:
1730                 return self._href.ticket(args), '%s:%s' % (module, args), 1, ''
1731         elif module == 'wiki':
1732Index: templates/ticket_workflow_qarmt.cs
1733===================================================================
1734--- templates/ticket_workflow_qarmt.cs  (revision 0)
1735+++ templates/ticket_workflow_qarmt.cs  (revision 0)
1736@@ -0,0 +1,95 @@
1737+<?cs
1738+if !ticket.action ?><?cs
1739+  set:ticket.action = 'leave' ?><?cs
1740+/if ?><?cs
1741+def action_radio(id) ?>
1742+  <input type="radio" id="<?cs var id ?>" name="action" value="<?cs var id ?>"
1743+    <?cs if $ticket.action == $id ?> checked="checked"<?cs /if ?> /><?cs
1744+/def ?>
1745+
1746+<?cs
1747+if ticket.workflow.action.leave ?><?cs
1748+  call:action_radio('leave') ?>
1749+  <label for="leave">leave as <?cs var:ticket.status ?></label><br /><?cs
1750+/if ?><?cs
1751+if ticket.workflow.action.accept ?><?cs
1752+  call action_radio('accept') ?>
1753+  <label for="accept">accept ticket</label><br /><?cs
1754+/if ?><?cs
1755+if ticket.workflow.action.resolve ?><?cs
1756+  call:action_radio('resolve') ?>
1757+  <label for="resolve">resolve</label>
1758+  <label for="resolve_resolution">as:</label><?cs
1759+  call:hdf_select(enums.resolution, "resolve_resolution",
1760+                  args.resolve_resolution) ?><br /><?cs
1761+/if ?><?cs
1762+if ticket.workflow.action.verify ?><?cs
1763+  call action_radio('verify') ?>
1764+  <label for="verify">verify ticket</label><br /><?cs
1765+/if ?><?cs
1766+if ticket.workflow.action.close ?><?cs
1767+  call action_radio('close') ?>
1768+  <label for="close">close ticket</label><br /><?cs
1769+/if ?><?cs
1770+if ticket.workflow.action.reopen ?><?cs
1771+  call:action_radio('reopen') ?>
1772+  <label for="reopen">reopen ticket</label><br /><?cs
1773+/if ?><?cs
1774+if ticket.workflow.action.retest ?><?cs
1775+  call:action_radio('retest') ?>
1776+  <label for="retest">retest ticket</label><br /><?cs
1777+/if ?><?cs
1778+if ticket.workflow.action.reassign ?><?cs
1779+  call:action_radio('reassign') ?>
1780+  <label for="reassign">reassign</label>
1781+  <label for="reassign_owner">to:</label>
1782+  <input type="text" id="reassign_owner" name="reassign_owner" size="40"
1783+    value=<?cs if args.reassign_to ?>"<?cs var:args.reassign_to ?>"
1784+          <?cs else ?>"<?cs var:trac.authname ?>"
1785+          <?cs /if ?> /><?cs
1786+/if ?>
1787+
1788+<?cs
1789+if ticket.workflow.action.resolve || ticket.workflow.action.reassign ?>
1790+  <script type="text/javascript"><?cs
1791+  if ticket.workflow.action.resolve ?>
1792+    var resolve = document.getElementById("resolve");<?cs
1793+  /if ?><?cs
1794+  if ticket.workflow.action.reassign ?>
1795+    var reassign = document.getElementById("reassign");<?cs
1796+  /if ?>
1797+    var updateActionFields = function() {<?cs
1798+  if ticket.workflow.action.resolve ?>
1799+      enableControl('resolve_resolution', resolve.checked);<?cs
1800+  /if ?><?cs
1801+  if ticket.workflow.action.reassign ?>
1802+      enableControl('reassign_owner', reassign.checked);<?cs
1803+  /if ?>
1804+    };
1805+    addEvent(window, 'load', updateActionFields);<?cs
1806+  if ticket.workflow.action.leave ?>
1807+    addEvent(document.getElementById("leave"), 'click', updateActionFields);<?cs
1808+  /if ?><?cs
1809+  if ticket.workflow.action.accept ?>
1810+    addEvent(document.getElementById("accept"), 'click', updateActionFields);<?cs
1811+  /if ?><?cs
1812+  if ticket.workflow.action.resolve ?>
1813+    addEvent(resolve, 'click', updateActionFields);<?cs
1814+  /if ?><?cs
1815+  if ticket.workflow.action.verify ?>
1816+    addEvent(document.getElementById("verify"), 'click', updateActionFields);<?cs
1817+  /if ?><?cs
1818+  if ticket.workflow.action.close ?>
1819+    addEvent(document.getElementById("close"), 'click', updateActionFields);<?cs
1820+  /if ?><?cs
1821+  if ticket.workflow.action.reopen ?>
1822+    addEvent(document.getElementById("reopen"), 'click', updateActionFields);<?cs
1823+  /if ?><?cs
1824+  if ticket.workflow.action.retest ?>
1825+    addEvent(document.getElementById("retest"), 'click', updateActionFields);<?cs
1826+  /if ?><?cs
1827+  if ticket.workflow.action.reassign ?>
1828+    addEvent(reassign, 'click', updateActionFields);<?cs
1829+  /if ?>
1830+  </script>
1831+<?cs /if ?>
1832Index: templates/ticket.cs
1833===================================================================
1834--- templates/ticket.cs (revision 1098)
1835+++ templates/ticket.cs (working copy)
1836@@ -23,7 +23,7 @@
1837 <div id="content" class="ticket">
1838 
1839  <h1>Ticket #<?cs var:ticket.id ?> <?cs
1840- if:ticket.status == 'closed' ?>(Closed: <?cs var:ticket.resolution ?>)<?cs
1841+ if:ticket.resolution ?>(<?cs var:ticket.status ?>: <?cs var:ticket.resolution ?>)<?cs
1842  elif:ticket.status != 'new' ?>(<?cs var:ticket.status ?>)<?cs
1843  /if ?></h1>
1844 
1845@@ -203,56 +203,21 @@
1846   </div><?cs /if ?>
1847  </fieldset>
1848 
1849- <fieldset id="action">
1850-  <legend>Action</legend><?cs
1851-  if:!ticket.action ?><?cs set:ticket.action = 'leave' ?><?cs
1852-  /if ?><?cs
1853-  def:action_radio(id) ?>
1854-   <input type="radio" id="<?cs var:id ?>" name="action" value="<?cs
1855-     var:id ?>"<?cs if:$ticket.action == $id ?> checked="checked"<?cs
1856-     /if ?> /><?cs
1857-  /def ?>
1858-  <?cs call:action_radio('leave') ?>
1859-  <label for="leave">leave as <?cs var:ticket.status ?></label><br /><?cs
1860-  if $ticket.status == "new" ?>
1861-   <?cs call:action_radio('accept') ?>
1862-   <label for="accept">accept ticket</label><br /><?cs
1863-  /if ?><?cs
1864-  if $ticket.status == "closed" ?>
1865-   <?cs call:action_radio('reopen') ?>
1866-   <label for="reopen">reopen ticket</label><br /><?cs
1867-  /if ?><?cs
1868-  if $ticket.status == "new" || $ticket.status == "assigned" || $ticket.status == "reopened" ?>
1869-   <?cs call:action_radio('resolve') ?>
1870-   <label for="resolve">resolve</label>
1871-   <label for="resolve_resolution">as:</label>
1872-   <?cs call:hdf_select(enums.resolution, "resolve_resolution", args.resolve_resolution) ?><br />
1873-   <?cs call:action_radio('reassign') ?>
1874-   <label for="reassign">reassign</label>
1875-   <label for="reassign_owner">to:</label>
1876-   <input type="text" id="reassign_owner" name="reassign_owner" size="40" value="<?cs
1877-     if:args.reassign_to ?><?cs var:args.reassign_to ?><?cs
1878-     else ?><?cs var:trac.authname ?><?cs /if ?>" /><?cs
1879-  /if ?><?cs
1880-  if $ticket.status == "new" || $ticket.status == "assigned" || $ticket.status == "reopened" ?>
1881-   <script type="text/javascript">
1882-     var resolve = document.getElementById("resolve");
1883-     var reassign = document.getElementById("reassign");
1884-     var updateActionFields = function() {
1885-       enableControl('resolve_resolution', resolve.checked);
1886-       enableControl('reassign_owner', reassign.checked);
1887-     };
1888-     addEvent(window, 'load', updateActionFields);
1889-     addEvent(document.getElementById("leave"), 'click', updateActionFields);<?cs
1890-    if $ticket.status == "new" ?>
1891-     addEvent(document.getElementById("accept"), 'click', updateActionFields);<?cs
1892-    /if ?>
1893-    addEvent(resolve, 'click', updateActionFields);
1894-    addEvent(reassign, 'click', updateActionFields);
1895-   </script><?cs
1896-  /if ?>
1897- </fieldset>
1898+ <?cs if ticket.workflow.template ?>
1899+  <fieldset id="action">
1900+   <legend>Action</legend>
1901+   <?cs include ticket.workflow.template ?>
1902+  </fieldset>
1903+ <?cs /if ?>
1904 
1905+ <?cs if ticket.workflow.error ?>
1906+   <div class="system-message">
1907+     <h2>Ticket Error</h2>
1908+     <p class="message"><?cs var ticket.workflow.error ?></p>
1909+     <strong>The ticket will not be saved.</strong>
1910+   </div>
1911+ <?cs /if ?>
1912+
1913  <script type="text/javascript" src="<?cs
1914    var:htdocs_location ?>js/wikitoolbar.js"></script>
1915 
1916Index: templates/roadmap.cs
1917===================================================================
1918--- templates/roadmap.cs        (revision 1098)
1919+++ templates/roadmap.cs        (working copy)
1920@@ -22,6 +22,7 @@
1921       var:milestone.name ?></em></a></h2>
1922     <p class="date"><?cs if:milestone.date ?>
1923      <?cs var:milestone.date ?><?cs else ?>No date set<?cs /if ?>
1924+     <?cs if:milestone.owner ?>&nbsp;(<?cs var:milestone.owner ?>)<?cs /if ?>
1925     </p>
1926     <?cs with:stats = milestone.stats ?>
1927      <?cs if:#stats.total_tickets > #0 ?>
1928Index: templates/ticket_workflow_simple.cs
1929===================================================================
1930--- templates/ticket_workflow_simple.cs (revision 0)
1931+++ templates/ticket_workflow_simple.cs (revision 0)
1932@@ -0,0 +1,74 @@
1933+<?cs
1934+if !ticket.action ?><?cs
1935+  set:ticket.action = 'leave' ?><?cs
1936+/if ?><?cs
1937+def action_radio(id) ?>
1938+  <input type="radio" id="<?cs var id ?>" name="action" value="<?cs var id ?>"
1939+    <?cs if $ticket.action == $id ?> checked="checked"<?cs /if ?> /><?cs
1940+/def ?>
1941+
1942+<?cs
1943+if ticket.workflow.action.leave ?><?cs
1944+  call:action_radio('leave') ?>
1945+  <label for="leave">leave as <?cs var:ticket.status ?></label><br /><?cs
1946+/if ?><?cs
1947+if ticket.workflow.action.accept ?><?cs
1948+  call action_radio('accept') ?>
1949+  <label for="accept">accept ticket</label><br /><?cs
1950+/if ?><?cs
1951+if ticket.workflow.action.resolve ?><?cs
1952+  call:action_radio('resolve') ?>
1953+  <label for="resolve">resolve</label>
1954+  <label for="resolve_resolution">as:</label><?cs
1955+  call:hdf_select(enums.resolution, "resolve_resolution",
1956+                  args.resolve_resolution) ?><br /><?cs
1957+/if ?><?cs
1958+if ticket.workflow.action.reopen ?><?cs
1959+  call:action_radio('reopen') ?>
1960+  <label for="reopen">reopen ticket</label><br /><?cs
1961+/if ?><?cs
1962+if ticket.workflow.action.reassign ?><?cs
1963+  call:action_radio('reassign') ?>
1964+  <label for="reassign">reassign</label>
1965+  <label for="reassign_owner">to:</label>
1966+  <input type="text" id="reassign_owner" name="reassign_owner" size="40"
1967+    value=<?cs if args.reassign_to ?>"<?cs var:args.reassign_to ?>"
1968+          <?cs else ?>"<?cs var:trac.authname ?>"
1969+          <?cs /if ?> /><?cs
1970+/if ?>
1971+
1972+<?cs
1973+if ticket.workflow.action.resolve || ticket.workflow.action.reassign ?>
1974+  <script type="text/javascript"><?cs
1975+  if ticket.workflow.action.resolve ?>
1976+    var resolve = document.getElementById("resolve");<?cs
1977+  /if ?><?cs
1978+  if ticket.workflow.action.reassign ?>
1979+    var reassign = document.getElementById("reassign");<?cs
1980+  /if ?>
1981+    var updateActionFields = function() {<?cs
1982+  if ticket.workflow.action.resolve ?>
1983+      enableControl('resolve_resolution', resolve.checked);<?cs
1984+  /if ?><?cs
1985+  if ticket.workflow.action.reassign ?>
1986+      enableControl('reassign_owner', reassign.checked);<?cs
1987+  /if ?>
1988+    };
1989+    addEvent(window, 'load', updateActionFields);<?cs
1990+  if ticket.workflow.action.leave ?>
1991+    addEvent(document.getElementById("leave"), 'click', updateActionFields);<?cs
1992+  /if ?><?cs
1993+  if ticket.workflow.action.accept ?>
1994+    addEvent(document.getElementById("accept"), 'click', updateActionFields);<?cs
1995+  /if ?><?cs
1996+  if ticket.workflow.action.resolve ?>
1997+    addEvent(resolve, 'click', updateActionFields);<?cs
1998+  /if ?><?cs
1999+  if ticket.workflow.action.reopen ?>
2000+    addEvent(document.getElementById("reopen"), 'click', updateActionFields);<?cs
2001+  /if ?><?cs
2002+  if ticket.workflow.action.reassign ?>
2003+    addEvent(reassign, 'click', updateActionFields);<?cs
2004+  /if ?>
2005+  </script>
2006+<?cs /if ?>
2007Index: templates/newticket.cs
2008===================================================================
2009--- templates/newticket.cs      (revision 1098)
2010+++ templates/newticket.cs      (working copy)
2011@@ -69,11 +69,26 @@
2012   </div><?cs /if ?>
2013  </fieldset>
2014 
2015+ <?cs if newticket.workflow.template ?>
2016+  <fieldset id="action">
2017+   <legend>Action</legend>
2018+   <?cs include newticket.workflow.template ?>
2019+  </fieldset>
2020+ <?cs /if ?>
2021+
2022+ <?cs if newticket.workflow.error ?>
2023+   <div class="system-message">
2024+     <h2>Ticket Error</h2>
2025+     <p class="message"><?cs var newticket.workflow.error ?></p>
2026+     <strong>The ticket will not be created.</strong>
2027+   </div>
2028+ <?cs /if ?>
2029+
2030  <script type="text/javascript" src="<?cs
2031    var:htdocs_location ?>js/wikitoolbar.js"></script>
2032 
2033  <div class="buttons">
2034-  <input type="submit" value="Preview" />&nbsp;
2035+  <input type="submit" name="preview" value="Preview" />&nbsp;
2036   <input type="submit" name="create" value="Submit ticket" />
2037  </div>
2038 </form>
2039Index: templates/timeline_rss.cs
2040===================================================================
2041--- templates/timeline_rss.cs   (revision 1098)
2042+++ templates/timeline_rss.cs   (working copy)
2043@@ -47,12 +47,24 @@
2044                              $item.href, $item.msg_escwiki)
2045         ?><?cs elif:item.type == #3
2046         ?><!-- Closed ticket --> <?cs call:rss_item('Ticket',
2047-                             'Ticket #'+$item.idata+' resolved: '+$item.shortmsg,
2048+                             'Ticket #'+$item.idata+' closed: '+$item.shortmsg,
2049                              $item.href, $item.msg_escwiki)
2050         ?><?cs elif:item.type == #4
2051         ?><!-- Reopened ticket --><?cs call:rss_item('Ticket',
2052                              '#'+$item.idata+' reopened: '+$item.shortmsg,
2053                              $item.href, $item.msg_escwiki)
2054+        ?><?cs elif:item.type == #7
2055+        ?><!-- Verified ticket --><?cs call:rss_item('Ticket',
2056+                             '#'+$item.idata+' verified: '+$item.shortmsg,
2057+                             $item.href, $item.msg_escwiki)
2058+        ?><?cs elif:item.type == #8
2059+        ?><!-- Resolved ticket --> <?cs call:rss_item('Ticket',
2060+                             'Ticket #'+$item.idata+' resolved: '+$item.shortmsg,
2061+                             $item.href, $item.msg_escwiki)
2062+        ?><?cs elif:item.type == #9
2063+        ?><!-- Retested ticket --><?cs call:rss_item('Ticket',
2064+                             '#'+$item.idata+' retested: '+$item.shortmsg,
2065+                             $item.href, $item.msg_escwiki)
2066         ?><?cs elif:item.type == #5
2067         ?><!-- Wiki change --><?cs call:rss_item('Wiki',
2068                              $item.tdata+" page edited.",
2069@@ -66,4 +78,4 @@
2070         <?cs /if ?>
2071       <?cs /each ?>
2072     </channel>
2073-</rss>
2074\ No newline at end of file
2075+</rss>
2076Index: templates/milestone.cs
2077===================================================================
2078--- templates/milestone.cs      (revision 1098)
2079+++ templates/milestone.cs      (working copy)
2080@@ -56,6 +56,11 @@
2081       var:milestone.name ?>" />
2082    </div>
2083    <div class="field">
2084+    <label for="owner">Owner of the milestone (Release Manager):</label><br />
2085+    <input type="text" id="owner" name="owner" size="32" value="<?cs
2086+      var:milestone.owner ?>" />
2087+   </div>
2088+   <div class="field">
2089     <label for="datemode">Completion date:</label><br />
2090     <select name="datemode" id="datemode"
2091         onchange="enableControl('date',this.value=='manual');
2092@@ -109,6 +114,7 @@
2093  <?cs else ?>
2094   <em class="date"><?cs if:milestone.date ?>
2095    <?cs var:milestone.date ?><?cs else ?>No date set<?cs /if ?>
2096+   <?cs if:milestone.owner ?>&nbsp;(<?cs var:milestone.owner ?>)<?cs /if ?>
2097   </em>
2098   <div class="descr"><?cs var:milestone.descr ?></div>
2099  <?cs /if ?>
2100@@ -121,7 +127,7 @@
2101   <thead><tr>
2102    <th class="name" rowspan="2"><?cs var:milestone.stats.grouped_by ?></th>
2103    <th class="tickets" scope="col" colspan="2">Tickets</th>
2104-   <th class="progress" rowspan="2">Percent Resolved</th>
2105+   <th class="progress" rowspan="2">Percent Completed</th>
2106   </tr><tr>
2107    <th class="open" scope="col">Active</th>
2108    <th class="closed" scope="col">Closed</th>
2109Index: templates/timeline.cs
2110===================================================================
2111--- templates/timeline.cs       (revision 1098)
2112+++ templates/timeline.cs       (working copy)
2113@@ -60,6 +60,13 @@
2114 
2115 <?cs each:item = timeline.items ?>
2116  <?cs call:day_separator(item.date) ?>
2117+ <?cs if:item.tdata && item.message ?>
2118+  <?cs set:ticketmsg = $item.tdata + ' - ' + $item.message ?>
2119+ <?cs elif:item.tdata ?>
2120+  <?cs set:ticketmsg = $item.tdata ?>
2121+ <?cs else ?>
2122+  <?cs set:ticketmsg = $item.message ?>
2123+ <?cs /if ?>
2124  <?cs if:item.type == #1 ?><!-- Changeset -->
2125   <?cs call:tlitem(item.href, 'changeset',
2126     'Changeset <em>['+$item.idata+']</em> by '+$item.author,$item.node_list+item.message) ?>
2127@@ -67,17 +74,20 @@
2128   <?cs call:tlitem(item.href, 'newticket',
2129     'Ticket <em>#'+$item.idata+'</em> created by '+$item.author, item.message) ?>
2130  <?cs elif:item.type == #3 ?><!-- Closed ticket -->
2131-  <?cs if:item.message ?>
2132-   <?cs set:imessage = ' - ' + $item.message ?>
2133-  <?cs else ?>
2134-   <?cs set:imessage = '' ?>
2135-  <?cs /if ?>
2136   <?cs call:tlitem(item.href, 'closedticket',
2137-    'Ticket <em>#'+$item.idata+'</em> resolved by '+$item.author,
2138-    $item.tdata+$imessage) ?>
2139+    'Ticket <em>#'+$item.idata+'</em> closed by '+$item.author, $ticketmsg) ?>
2140  <?cs elif:item.type == #4 ?><!-- Reopened ticket -->
2141-  <?cs call:tlitem(item.href, 'newticket',
2142-    'Ticket <em>#'+$item.idata+'</em> reopened by '+$item.author, '') ?>
2143+  <?cs call:tlitem(item.href, 'reopenedticket',
2144+    'Ticket <em>#'+$item.idata+'</em> reopened by '+$item.author, item.message) ?>
2145+ <?cs elif:item.type == #7 ?><!-- Verified ticket -->
2146+  <?cs call:tlitem(item.href, 'closedticket',
2147+    'Ticket <em>#'+$item.idata+'</em> verified by '+$item.author, $ticketmsg) ?>
2148+ <?cs elif:item.type == #8 ?><!-- Resolved ticket -->
2149+  <?cs call:tlitem(item.href, 'resolvedticket',
2150+    'Ticket <em>#'+$item.idata+'</em> resolved by '+$item.author, $ticketmsg) ?>
2151+ <?cs elif:item.type == #9 ?><!-- Retested ticket -->
2152+  <?cs call:tlitem(item.href, 'reopenedticket',
2153+    'Ticket <em>#'+$item.idata+'</em> retested by '+$item.author, item.message) ?>
2154  <?cs elif:item.type == #5 ?><!-- Wiki change -->
2155   <?cs call:tlitem(item.href, 'wiki',
2156     '<em>'+$item.tdata+'</em> edited by '+$item.author, item.message) ?>