Index: wiki-default/TracIni
===================================================================
--- wiki-default/TracIni	(revision 1064)
+++ wiki-default/TracIni	(working copy)
@@ -22,6 +22,7 @@
 See also: TracLogging
 
 == [ticket] ==
+|| workflow || Ticket workflow class.  If not specified, it is ''trac.workflows.SimpleWorkflow'' ||
 || default_version   || Default version for newly created tickets ||
 || default_severity  || Default severity for newly created tickets ||
 || default_priority  || Default priority for newly created tickets ||
Index: setup.py
===================================================================
--- setup.py	(revision 1064)
+++ setup.py	(working copy)
@@ -198,7 +198,8 @@
       author_email="info@edgewall.com",
       license=LICENSE,
       url=URL,
-      packages=['trac', 'trac.upgrades', 'trac.wikimacros', 'trac.mimeviewers'],
+      packages=['trac', 'trac.upgrades', 'trac.wikimacros', 'trac.mimeviewers',
+                'trac.workflows'],
       data_files=[(_p('share/trac/templates'), glob('templates/*')),
                   (_p('share/trac/htdocs'), glob(_p('htdocs/*.*')) + [_p('htdocs/README')]),
                   (_p('share/trac/htdocs/css'), glob(_p('htdocs/css/*'))),
Index: trac/workflows/Base.py
===================================================================
--- trac/workflows/Base.py	(revision 0)
+++ trac/workflows/Base.py	(revision 0)
@@ -0,0 +1,87 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2003, 2004 Edgewall Software
+# Copyright (C) 2003, 2004 Jonas Borgström <jonas@edgewall.com>
+#
+# Trac is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Pavel Kourochka <pkou@ua.fm>
+#
+# Abstract workflow definition
+
+class WorkflowBase:
+    """
+    Generic workflow class for Trac.
+    """
+
+    def __init__(self, env, db, user):
+        """
+        Constructor for workflow class.
+        """
+        self.env = env
+        self.db = db
+        self.user = user
+
+    def get_actions(self, ticket):
+        """
+        For existing tickets only.
+        Return the list of available actions for specified ticket.
+        """
+        raise Exception, "WorkflowBase::get_actions not implemented"
+
+    def do_action(self, ticket, action, args):
+        """
+        For new and existing tickets.
+        Perform action on a ticket.  For new tickets, action name is 'create'.
+        """
+        raise Exception, "WorkflowBase::do_action not implemented"
+
+    def get_actions_template(self, ticket):
+        """
+        For new and existing tickets.
+        Return the name of ClearSilver template file for the workflow.
+        Return None if no additional template is required.
+        """
+        return None
+
+    def init_template(self, ticket, hdf):
+        """
+        For new and existing tickets.
+        Initialize ClearSilver variables for actions template.
+        Called if get_actions_template() returns file name only.
+        """
+        pass
+
+    def validate(self, ticket):
+        """
+        For new and existing tickets.
+        Validate ticket.
+        Return list of Wiki strings that describe errors in the ticket.
+        """
+        return []
+
+    def on_insert(self, ticket):
+        """
+        For new tickets only.
+        Update ticket fields just before inserting the ticket into database.
+        """
+        pass
+
+    def on_update(self, ticket):
+        """
+        For existing tickets only.
+        Update ticket fields just before saving the ticket into database.
+        """
+        pass
Index: trac/workflows/__init__.py
===================================================================
--- trac/workflows/__init__.py	(revision 0)
+++ trac/workflows/__init__.py	(revision 0)
@@ -0,0 +1 @@
+__all__ = ['Base', 'SimpleWorkflow']
Index: trac/workflows/SimpleWorkflow.py
===================================================================
--- trac/workflows/SimpleWorkflow.py	(revision 0)
+++ trac/workflows/SimpleWorkflow.py	(revision 0)
@@ -0,0 +1,94 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2003, 2004 Edgewall Software
+# Copyright (C) 2003, 2004 Jonas Borgström <jonas@edgewall.com>
+#
+# Trac is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Pavel Kourochka <pkou@ua.fm>
+#
+# Simple workflow definition (as in Trac 0.8)
+
+from trac.workflows.Base import WorkflowBase
+
+class SimpleWorkflow(WorkflowBase):
+
+    def get_actions(self, ticket):
+        actions = {
+            'new':      ['leave', 'resolve', 'reassign', 'accept'],
+            'assigned': ['leave', 'resolve', 'reassign'          ],
+            'reopened': ['leave', 'resolve', 'reassign'          ],
+            'closed':   ['leave',                        'reopen']
+        }
+        return actions.get(ticket['status'], ['leave'])
+
+    def do_action(self, ticket, action, args):
+        if action == 'accept':
+            ticket['status'] = 'assigned'
+            ticket['owner'] = self.user
+        elif action == 'resolve':
+            ticket['status'] = 'closed'
+            ticket['resolution'] = args.get('resolve_resolution')
+        elif action == 'reassign':
+            ticket['owner'] = args.get('reassign_owner')
+            ticket['status'] = 'new'
+        elif action == 'reopen':
+            ticket['status'] = 'reopened'
+            ticket['resolution'] = ''
+
+    def get_actions_template(self, ticket):
+        if ticket.has_key('id'):
+            return 'ticket_workflow_simple.cs'
+        else:
+            return None
+
+    def init_template(self, ticket, hdf):
+        WorkflowBase.init_template(self, ticket, hdf)
+        if ticket.has_key('id'):
+            for a in self.get_actions(ticket):
+                hdf.setValue('ticket.workflow.action.' + a, '1')
+
+    def validate(self, ticket):
+        err = WorkflowBase.validate(self, ticket)
+        if not ticket.get('summary'):
+            err.append("The ticket must contain '''Summary''' field.")
+        return err
+
+    def on_insert(self, ticket):
+        WorkflowBase.on_insert(self, ticket)
+
+        # The owner field defaults to the component owner
+        cursor = self.db.cursor()
+        if ticket.get('owner', '') == '':
+            cursor.execute('SELECT owner FROM component '
+                           'WHERE name=%s', ticket.get('component', ''))
+            ticket['owner'] = cursor.fetchone()[0] or ''
+
+    def on_update(self, ticket):
+        WorkflowBase.on_update(self, ticket)
+        if not ticket._old: return # Not modified
+
+        # If the component is changed on a 'new' ticket then owner field
+        # is updated accordingly. (#623).
+        cursor = self.db.cursor()
+        if ticket['status'] == 'new' and ticket._old.has_key('component') and \
+               not ticket._old.has_key('owner'):
+            cursor.execute('SELECT owner FROM component '
+                           'WHERE name=%s', ticket._old['component'])
+            old_owner = cursor.fetchone()[0]
+            if ticket['owner'] == old_owner:
+                cursor.execute('SELECT owner FROM component '
+                               'WHERE name=%s', ticket['component'])
+                ticket['owner'] = cursor.fetchone()[0] or ''
Index: trac/Ticket.py
===================================================================
--- trac/Ticket.py	(revision 1064)
+++ trac/Ticket.py	(working copy)
@@ -130,19 +130,6 @@
 
         if not self._old and not comment: return # Not modified
 
-        # If the component is changed on a 'new' ticket then owner field
-        # is updated accordingly. (#623).
-        if self['status'] == 'new' and self._old.has_key('component') and \
-               not self._old.has_key('owner'):
-            cursor.execute('SELECT owner FROM component '
-                           'WHERE name=%s', self._old['component'])
-            old_owner = cursor.fetchone()[0]
-            if self['owner'] == old_owner:
-                cursor.execute('SELECT owner FROM component '
-                               'WHERE name=%s', self['component'])
-                self['owner'] = cursor.fetchone()[0]
-           
-
         for name in self._old.keys():
             if name[:7] == 'custom_':
                 fname = name[7:]
@@ -264,25 +251,38 @@
         i += 1
 
 
+def get_workflow(env, db, user):
+#    from trac.workflows.Simple import SimpleWorkflow
+#    return SimpleWorkflow(env, db, user)
+    modulename = env.get_config('ticket', 'workflow', \
+                                'trac.workflows.SimpleWorkflow')
+    i = modulename.rfind('.')
+    if i == -1:
+        classname = modulename
+    else:
+        classname = modulename[i+1:]
+
+    module = __import__(modulename, globals(), locals(), [classname])
+    constructor = getattr(module, classname)
+    workflow = constructor(env, db, user)
+
+    from workflows.Base import WorkflowBase
+    if not isinstance(workflow, WorkflowBase):
+        raise EnvironmentError, "Workflow class %s from %s must be " \
+                                "descendant of class WorkflowBase from " \
+                                "trac.workflows.base" \
+                                % (classname, modulename)
+
+    return workflow
+
+
 class NewticketModule(Module):
     template_name = 'newticket.cs'
 
-    def create_ticket(self):
-        if not self.args.get('summary'):
-            raise util.TracError('Tickets must contain Summary.')
-
-        ticket = Ticket()
-        ticket.populate(self.args)
+    def create_ticket(self, ticket, workflow):
         ticket.setdefault('reporter',self.req.authname)
 
-        # The owner field defaults to the component owner
-        cursor = self.db.cursor()
-        if ticket.get('component') and ticket.get('owner', '') == '':
-            cursor.execute('SELECT owner FROM component '
-                           'WHERE name=%s', ticket['component'])
-            owner = cursor.fetchone()[0]
-            ticket['owner'] = owner
-
+        workflow.on_insert(ticket)
         tktid = ticket.insert(self.db)
 
         # Notify
@@ -294,11 +294,25 @@
     def render (self):
         self.perm.assert_permission(perm.TICKET_CREATE)
 
-        if self.args.has_key('create'):
-            self.create_ticket()
+        ticket = Ticket()
 
-        ticket = Ticket()
+        preview = self.args.has_key('preview')
+        do_create = self.args.has_key('create')
         ticket.populate(self.args)
+
+        workflow = get_workflow(self.env, self.db, self.req.authname)
+
+        # Validate the ticket
+        err = []
+        if preview or do_create:
+            err.extend(workflow.validate(ticket))
+        if len(err) != 0: preview = 1
+
+        # Create the ticket if not in preview mode
+        if not preview and do_create:
+            workflow.do_action(ticket, 'create', self.args)
+            self.create_ticket(ticket, workflow)
+
         ticket.setdefault('component',
                           self.env.get_config('ticket', 'default_component'))
         ticket.setdefault('milestone',
@@ -320,6 +334,14 @@
         evals = util.mydict(zip(ticket.keys(),
                                 map(lambda x: util.escape(x), ticket.values())))
         util.add_to_hdf(evals, self.req.hdf, 'newticket')
+        if len(err) != 0:
+            self.req.hdf.setValue('newticket.workflow.error',
+                              wiki_to_html(' * ' + '\n * '.join(err),
+                                           self.req.hdf, self.env, self.db))
+        tpl = workflow.get_actions_template(ticket)
+        if tpl:
+            self.req.hdf.setValue('newticket.workflow.template', tpl)
+            workflow.init_template(ticket, self.req.hdf)
 
         util.sql_to_hdf(self.db, 'SELECT name FROM component ORDER BY name',
                         self.req.hdf, 'newticket.components')
@@ -334,38 +356,18 @@
 class TicketModule (Module):
     template_name = 'ticket.cs'
 
-    def save_changes (self, id):
+    def save_changes (self, ticket, workflow):
         self.perm.assert_permission (perm.TICKET_MODIFY)
-        ticket = Ticket(self.db, id)
 
-        if not self.args.get('summary'):
-            raise util.TracError('Tickets must contain Summary.')
-
         if self.args.has_key('description'):
             self.perm.assert_permission (perm.TICKET_ADMIN)
 
         if self.args.has_key('reporter'):
             self.perm.assert_permission (perm.TICKET_ADMIN)
 
-        # TODO: this should not be hard-coded like this
-        action = self.args.get('action', None)
-        if action == 'accept':
-            ticket['status'] =  'assigned'
-            ticket['owner'] = self.req.authname
-        if action == 'resolve':
-            ticket['status'] = 'closed'
-            ticket['resolution'] = self.args.get('resolve_resolution')
-        elif action == 'reassign':
-            ticket['owner'] = self.args.get('reassign_owner')
-            ticket['status'] = 'new'
-        elif action == 'reopen':
-            ticket['status'] = 'reopened'
-            ticket['resolution'] = ''
-
-        ticket.populate(self.args)
-
         now = int(time.time())
 
+        workflow.on_update(ticket)
         ticket.save_changes(self.db,
                             self.args.get('author', self.req.authname),
                             self.args.get('comment'),
@@ -373,7 +375,7 @@
 
         tn = TicketNotifyEmail(self.env)
         tn.notify(ticket, newticket=0, modtime=now)
-        self.req.redirect(self.env.href.ticket(id))
+        self.req.redirect(self.env.href.ticket(ticket['id']))
 
     def insert_ticket_data(self, hdf, id, ticket, reporter_id):
         """Insert ticket data into the hdf"""
@@ -431,27 +433,39 @@
     def render (self):
         self.perm.assert_permission (perm.TICKET_VIEW)
 
-        action = self.args.get('action', 'view')
-        preview = self.args.has_key('preview')
-
         if not self.args.has_key('id'):
             self.req.redirect(self.env.href.wiki())
 
         id = int(self.args.get('id'))
+        ticket = Ticket(self.db, id)
 
-        if not preview \
-               and action in ['leave', 'accept', 'reopen', 'resolve', 'reassign']:
-            self.save_changes (id)
+        action = self.args.get('action', None)
+        preview = self.args.has_key('preview')
+        if action or preview:
+            ticket.populate(self.args)
 
-        ticket = Ticket(self.db, id)
+        workflow = get_workflow(self.env, self.db, self.req.authname)
+
+        # Validate ticket
+        err = []
+        if action or preview:
+            actions = workflow.get_actions(ticket)
+            if action not in actions:
+                err.append("Invalid action '''%s''' is performed on the ticket. " \
+                           "Allowed actions are <''%s''>." % \
+                           (action, ', '.join(actions)))
+            err.extend(workflow.validate(ticket))
+        if len(err) != 0: preview = 1
+
+        # Save changes if not in preview mode
+        if not preview and action:
+            workflow.do_action(ticket, action, self.args)
+            self.save_changes(ticket, workflow)
+
         reporter_id = util.get_reporter_id(self.req)
 
         if preview:
-            # Use user supplied values
-            for field in Ticket.std_fields:
-                if self.args.has_key(field) and field != 'reporter':
-                    ticket[field] = self.args.get(field)
-            self.req.hdf.setValue('ticket.action', action)
+            if action: self.req.hdf.setValue('ticket.action', action)
             reporter_id = self.args.get('author')
             comment = self.args.get('comment')
             if comment:
@@ -462,6 +476,14 @@
                                                self.req.hdf, self.env, self.db))
 
         self.insert_ticket_data(self.req.hdf, id, ticket, reporter_id)
+        if len(err) != 0:
+            self.req.hdf.setValue('ticket.workflow.error',
+                              wiki_to_html(' * ' + '\n * '.join(err),
+                                           self.req.hdf, self.env, self.db))
+        tpl = workflow.get_actions_template(ticket)
+        if tpl:
+            self.req.hdf.setValue('ticket.workflow.template', tpl)
+            workflow.init_template(ticket, self.req.hdf)
 
         cursor = self.db.cursor()
         cursor.execute("SELECT max(id) FROM ticket")
Index: templates/ticket.cs
===================================================================
--- templates/ticket.cs	(revision 1064)
+++ templates/ticket.cs	(working copy)
@@ -208,56 +208,21 @@
   </div><?cs /if ?>
  </fieldset>
 
- <fieldset id="action">
-  <legend>Action</legend><?cs
-  if:!ticket.action ?><?cs set:ticket.action = 'leave' ?><?cs
-  /if ?><?cs
-  def:action_radio(id) ?>
-   <input type="radio" id="<?cs var:id ?>" name="action" value="<?cs
-     var:id ?>"<?cs if:$ticket.action == $id ?> checked="checked"<?cs
-     /if ?> /><?cs
-  /def ?>
-  <?cs call:action_radio('leave') ?>
-  <label for="leave">leave as <?cs var:ticket.status ?></label><br /><?cs
-  if $ticket.status == "new" ?>
-   <?cs call:action_radio('accept') ?>
-   <label for="accept">accept ticket</label><br /><?cs
-  /if ?><?cs
-  if $ticket.status == "closed" ?>
-   <?cs call:action_radio('reopen') ?>
-   <label for="reopen">reopen ticket</label><br /><?cs
-  /if ?><?cs
-  if $ticket.status == "new" || $ticket.status == "assigned" || $ticket.status == "reopened" ?>
-   <?cs call:action_radio('resolve') ?>
-   <label for="resolve">resolve</label>
-   <label for="resolve_resolution">as:</label>
-   <?cs call:hdf_select(enums.resolution, "resolve_resolution", args.resolve_resolution) ?><br />
-   <?cs call:action_radio('reassign') ?>
-   <label for="reassign">reassign</label>
-   <label for="reassign_owner">to:</label>
-   <input type="text" id="reassign_owner" name="reassign_owner" size="40" value="<?cs
-     if:args.reassign_to ?><?cs var:args.reassign_to ?><?cs
-     else ?><?cs var:trac.authname ?><?cs /if ?>" /><?cs
-  /if ?><?cs
-  if $ticket.status == "new" || $ticket.status == "assigned" || $ticket.status == "reopened" ?>
-   <script type="text/javascript">
-     var resolve = document.getElementById("resolve");
-     var reassign = document.getElementById("reassign");
-     var updateActionFields = function() {
-       enableControl('resolve_resolution', resolve.checked);
-       enableControl('reassign_owner', reassign.checked);
-     };
-     addEvent(window, 'load', updateActionFields);
-     addEvent(document.getElementById("leave"), 'click', updateActionFields);<?cs
-    if $ticket.status == "new" ?>
-     addEvent(document.getElementById("accept"), 'click', updateActionFields);<?cs
-    /if ?>
-    addEvent(resolve, 'click', updateActionFields);
-    addEvent(reassign, 'click', updateActionFields);
-   </script><?cs
-  /if ?>
- </fieldset>
+ <?cs if ticket.workflow.template ?>
+  <fieldset id="action">
+   <legend>Action</legend>
+   <?cs include ticket.workflow.template ?>
+  </fieldset>
+ <?cs /if ?>
 
+ <?cs if ticket.workflow.error ?>
+   <div class="system-message">
+     <h2>Ticket Error</h2>
+     <p class="message"><?cs var ticket.workflow.error ?></p>
+     <strong>The ticket will not be saved.</strong>
+   </div>
+ <?cs /if ?>
+
  <div class="buttons">
   <input type="reset" value="Reset" />&nbsp;
   <input type="submit" name="preview" value="Preview" />&nbsp;
Index: templates/ticket_workflow_simple.cs
===================================================================
--- templates/ticket_workflow_simple.cs	(revision 0)
+++ templates/ticket_workflow_simple.cs	(revision 0)
@@ -0,0 +1,74 @@
+<?cs
+if !ticket.action ?><?cs
+  set:ticket.action = 'leave' ?><?cs
+/if ?><?cs
+def action_radio(id) ?>
+  <input type="radio" id="<?cs var id ?>" name="action" value="<?cs var id ?>"
+    <?cs if $ticket.action == $id ?> checked="checked"<?cs /if ?> /><?cs
+/def ?>
+
+<?cs
+if ticket.workflow.action.leave ?><?cs
+  call:action_radio('leave') ?>
+  <label for="leave">leave as <?cs var:ticket.status ?></label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.accept ?><?cs
+  call action_radio('accept') ?>
+  <label for="accept">accept ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.resolve ?><?cs
+  call:action_radio('resolve') ?>
+  <label for="resolve">resolve</label>
+  <label for="resolve_resolution">as:</label><?cs
+  call:hdf_select(enums.resolution, "resolve_resolution",
+                  args.resolve_resolution) ?><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.reopen ?><?cs
+  call:action_radio('reopen') ?>
+  <label for="reopen">reopen ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.reassign ?><?cs
+  call:action_radio('reassign') ?>
+  <label for="reassign">reassign</label>
+  <label for="reassign_owner">to:</label>
+  <input type="text" id="reassign_owner" name="reassign_owner" size="40"
+    value=<?cs if args.reassign_to ?>"<?cs var:args.reassign_to ?>"
+          <?cs else ?>"<?cs var:trac.authname ?>"
+          <?cs /if ?> /><?cs
+/if ?>
+
+<?cs
+if ticket.workflow.action.resolve || ticket.workflow.action.reassign ?>
+  <script type="text/javascript"><?cs
+  if ticket.workflow.action.resolve ?>
+    var resolve = document.getElementById("resolve");<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+    var reassign = document.getElementById("reassign");<?cs
+  /if ?>
+    var updateActionFields = function() {<?cs
+  if ticket.workflow.action.resolve ?>
+      enableControl('resolve_resolution', resolve.checked);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+      enableControl('reassign_owner', reassign.checked);<?cs
+  /if ?>
+    };
+    addEvent(window, 'load', updateActionFields);<?cs
+  if ticket.workflow.action.leave ?>
+    addEvent(document.getElementById("leave"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.accept ?>
+    addEvent(document.getElementById("accept"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.resolve ?>
+    addEvent(resolve, 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reopen ?>
+    addEvent(document.getElementById("reopen"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+    addEvent(reassign, 'click', updateActionFields);<?cs
+  /if ?>
+  </script>
+<?cs /if ?>
Index: templates/newticket.cs
===================================================================
--- templates/newticket.cs	(revision 1064)
+++ templates/newticket.cs	(working copy)
@@ -69,8 +69,23 @@
   </div><?cs /if ?>
  </fieldset>
 
+ <?cs if newticket.workflow.template ?>
+  <fieldset id="action">
+   <legend>Action</legend>
+   <?cs include newticket.workflow.template ?>
+  </fieldset>
+ <?cs /if ?>
+
+ <?cs if newticket.workflow.error ?>
+   <div class="system-message">
+     <h2>Ticket Error</h2>
+     <p class="message"><?cs var newticket.workflow.error ?></p>
+     <strong>The ticket will not be created.</strong>
+   </div>
+ <?cs /if ?>
+
  <div class="buttons">
-  <input type="submit" value="Preview" />&nbsp;
+  <input type="submit" name="preview" value="Preview" />&nbsp;
   <input type="submit" name="create" value="Submit ticket" />
  </div>
 </form>

