Code

- added a favicon
[roundup.git] / doc / customizing.txt
index 3e56a82a2fe9aa39711727db864f0cfd0c947ee4..243eb0335ffdabce601a7231826244bd6a96df2f 100644 (file)
@@ -2,7 +2,7 @@
 Customising Roundup
 ===================
 
-:Version: $Revision: 1.126 $
+:Version: $Revision: 1.132 $
 
 .. This document borrows from the ZopeBook section on ZPT. The original is at:
    http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx
@@ -317,7 +317,30 @@ Note: if you modify the schema, you'll most likely need to edit the
 
 A tracker schema defines what data is stored in the tracker's database.
 Schemas are defined using Python code in the ``dbinit.py`` module of your
-tracker. The "classic" schema looks like this (see below for the meaning
+tracker.
+
+The ``dbinit.py`` module
+------------------------
+
+The ``dbinit.py`` module contains two functions:
+
+**open**
+  This function defines what your tracker looks like on the inside, the
+  **schema** of the tracker. It defines the **Classes** and **properties**
+  on each class. It also defines the **security** for those Classes. The
+  next few sections describe how schemas work and what you can do with
+  them.
+**init**
+  This function is responsible for setting up the initial state of your
+  tracker. It's called exactly once - but the ``roundup-admin initialise``
+  command.  See the start of the section on `database content`_ for more
+  info about how this works.
+
+
+The "classic" schema
+--------------------
+
+The "classic" schema looks like this (see below for the meaning
 of ``'setkey'``)::
 
     pri = Class(db, "priority", name=String(), order=String())
@@ -831,7 +854,6 @@ Web Interface
 
 .. contents::
    :local:
-   :depth: 1
 
 The web interface is provided by the ``roundup.cgi.client`` module and
 is used by ``roundup.cgi``, ``roundup-server`` and ``ZRoundup``
@@ -1579,6 +1601,9 @@ hasPermission   specific to the "user" class - determine whether the
                 user has a Permission
 is_edit_ok      is the user allowed to Edit the current item?
 is_view_ok      is the user allowed to View the current item?
+is_retired      is the item retired?
+download_url    generates a url-quoted link for download of FileClass
+                item contents (ie. file<id>/<name>)
 =============== ========================================================
 
 Note that if you have a property of the same name as one of the above
@@ -1690,6 +1715,7 @@ menu        only on Link and Multilink properties - render a form select
             list for this property
 reverse     only on Multilink properties - produce a list of the linked
             items in reverse order
+isset       returns True if the property has been set to a value
 =========== ================================================================
 
 All of the above functions perform checks for permissions required to
@@ -1839,6 +1865,8 @@ as described below.
 Method          Description
 =============== ========================================================
 Batch           return a batch object using the supplied list
+url_quote       quote some text as safe for a URL (ie. space, %, ...)
+html_quote      quote some text as safe in HTML (ie. <, >, ...)
 =============== ========================================================
 
 You may add additional utility methods by writing them in your tracker
@@ -2262,18 +2290,25 @@ Examples
 
 .. contents::
    :local:
-   :depth: 1
+   :depth: 2
+
+
+Changing what's stored in the database
+--------------------------------------
+
+The following examples illustrate ways to change the information stored in
+the database.
 
 
 Adding a new field to the classic schema
-----------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 This example shows how to add a new constrained property (i.e. a
 selection of distinct values) to your tracker.
 
 
 Introduction
-~~~~~~~~~~~~
+::::::::::::
 
 To make the classic schema of roundup useful as a TODO tracking system
 for a group of systems administrators, it needed an extra data field per
@@ -2286,7 +2321,7 @@ best).
 
 
 Adding a field to the database
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+::::::::::::::::::::::::::::::
 
 This is the easiest part of the change. The category would just be a
 plain string, nothing fancy. To change what is in the database you need
@@ -2328,7 +2363,7 @@ is fiddling around so you can actually use the new category.
 
 
 Populating the new category class
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+:::::::::::::::::::::::::::::::::
 
 If you haven't initialised the database with the roundup-admin
 "initialise" command, then you can add the following to the tracker
@@ -2359,12 +2394,11 @@ If the database has already been initalised, then you need to use the
      roundup> exit...
      There are unsaved changes. Commit them (y/N)? y
 
-TODO: explain why order=1 in each case. Also, does key get set to "name"
-automatically when added via roundup-admin?
+TODO: explain why order=1 in each case.
 
 
 Setting up security on the new objects
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+::::::::::::::::::::::::::::::::::::::
 
 By default only the admin user can look at and change objects. This
 doesn't suit us, as we want any user to be able to create new categories
@@ -2417,7 +2451,7 @@ interface stuff.
 
 
 Changing the web left hand frame
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+::::::::::::::::::::::::::::::::
 
 We need to give the users the ability to create new categories, and the
 place to put the link to this functionality is in the left hand function
@@ -2459,7 +2493,7 @@ would see the "Categories" stuff.
 
 
 Setting up a page to edit categories
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+::::::::::::::::::::::::::::::::::::
 
 We defined code in the previous section which let users with the
 appropriate permissions see a link to a page which would let them edit
@@ -2579,7 +2613,7 @@ maybe a few extra to get the formatting correct).
 
 
 Adding the category to the issue
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+::::::::::::::::::::::::::::::::
 
 We now have the ability to create issues to our heart's content, but
 that is pointless unless we can assign categories to issues.  Just like
@@ -2607,7 +2641,7 @@ which contains the list of currently known categories.
 
 
 Searching on categories
-~~~~~~~~~~~~~~~~~~~~~~~
+:::::::::::::::::::::::
 
 We can add categories, and create issues with categories. The next
 obvious thing that we would like to be able to do, would be to search
@@ -2665,7 +2699,7 @@ The category search code above would expand to the following::
   </tr>
 
 Adding category to the default view
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+:::::::::::::::::::::::::::::::::::
 
 We can now add categories, add issues with categories, and search for
 issues based on categories. This is everything that we need to do;
@@ -2706,259 +2740,188 @@ The option that we are interested in is the ``:columns=`` one which
 tells roundup which fields of the issue to display. Simply add
 "category" to that list and it all should work.
 
+Adding a time log to your issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Adding in state transition control
-----------------------------------
+We want to log the dates and amount of time spent working on issues, and
+be able to give a summary of the total time spent on a particular issue.
 
-Sometimes tracker admins want to control the states that users may move
-issues to. You can do this by following these steps:
+1. Add a new class to your tracker ``dbinit.py``::
 
-1. make "status" a required variable. This is achieved by adding the
-   following to the top of the form in the ``issue.item.html``
-   template::
+    # storage for time logging
+    timelog = Class(db, "timelog", period=Interval())
 
-     <input type="hidden" name="@required" value="status">
+   Note that we automatically get the date of the time log entry
+   creation through the standard property "creation".
 
-   this will force users to select a status.
+2. Link to the new class from your issue class (again, in
+   ``dbinit.py``)::
 
-2. add a Multilink property to the status class::
+    issue = IssueClass(db, "issue", 
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    priority=Link("priority"), status=Link("status"),
+                    times=Multilink("timelog"))
 
-     stat = Class(db, "status", ... , transitions=Multilink('status'),
-                  ...)
+   the "times" property is the new link to the "timelog" class.
 
-   and then edit the statuses already created, either:
+3. We'll need to let people add in times to the issue, so in the web
+   interface we'll have a new entry field. This is a special field
+   because unlike the other fields in the issue.item template, it
+   affects a different item (a timelog item) and not the template's
+   item, an issue. We have a special syntax for form fields that affect
+   items other than the template default item (see the cgi 
+   documentation on `special form variables`_). In particular, we add a
+   field to capture a new timelog item's perdiod::
 
-   a. through the web using the class list -> status class editor, or
-   b. using the roundup-admin "set" command.
+    <tr> 
+     <th>Time Log</th> 
+     <td colspan=3><input type="text" name="timelog-1@period" /> 
+      <br />(enter as '3y 1m 4d 2:40:02' or parts thereof) 
+     </td> 
+    </tr> 
+         
+   and another hidden field that links that new timelog item (new
+   because it's marked as having id "-1") to the issue item. It looks
+   like this::
 
-3. add an auditor module ``checktransition.py`` in your tracker's
-   ``detectors`` directory, for example::
+     <input type="hidden" name="@link@times" value="timelog-1" />
 
-     def checktransition(db, cl, nodeid, newvalues):
-         ''' Check that the desired transition is valid for the "status"
-             property.
-         '''
-         if not newvalues.has_key('status'):
-             return
-         current = cl.get(nodeid, 'status')
-         new = newvalues['status']
-         if new == current:
-             return
-         ok = db.status.get(current, 'transitions')
-         if new not in ok:
-             raise ValueError, 'Status not allowed to move from "%s" to "%s"'%(
-                 db.status.get(current, 'name'), db.status.get(new, 'name'))
+   On submission, the "-1" timelog item will be created and assigned a
+   real item id. The "times" property of the issue will have the new id
+   added to it.
 
-     def init(db):
-         db.issue.audit('set', checktransition)
+4. We want to display a total of the time log times that have been
+   accumulated for an issue. To do this, we'll need to actually write
+   some Python code, since it's beyond the scope of PageTemplates to
+   perform such calculations. We do this by adding a method to the
+   TemplatingUtils class in our tracker ``interfaces.py`` module::
 
-4. in the ``issue.item.html`` template, change the status editing bit
-   from::
+    class TemplatingUtils:
+        ''' Methods implemented on this class will be available to HTML
+            templates through the 'utils' variable.
+        '''
+        def totalTimeSpent(self, times):
+            ''' Call me with a list of timelog items (which have an
+                Interval "period" property)
+            '''
+            total = Interval('0d')
+            for time in times:
+                total += time.period._value
+            return total
 
-    <th>Status</th>
-    <td tal:content="structure context/status/menu">status</td>
+   Replace the ``pass`` line if one appears in your TemplatingUtils
+   class. As indicated in the docstrings, we will be able to access the
+   ``totalTimeSpent`` method via the ``utils`` variable in our templates.
 
-   to::
+5. Display the time log for an issue::
 
-    <th>Status</th>
-    <td>
-     <select tal:condition="context/id" name="status">
-      <tal:block tal:define="ok context/status/transitions"
-                 tal:repeat="state db/status/list">
-       <option tal:condition="python:state.id in ok"
-               tal:attributes="
-                    value state/id;
-                    selected python:state.id == context.status.id"
-               tal:content="state/name"></option>
-      </tal:block>
-     </select>
-     <tal:block tal:condition="not:context/id"
-                tal:replace="structure context/status/menu" />
-    </td>
+     <table class="otherinfo" tal:condition="context/times">
+      <tr><th colspan="3" class="header">Time Log
+       <tal:block
+            tal:replace="python:utils.totalTimeSpent(context.times)" />
+      </th></tr>
+      <tr><th>Date</th><th>Period</th><th>Logged By</th></tr>
+      <tr tal:repeat="time context/times">
+       <td tal:content="time/creation"></td>
+       <td tal:content="time/period"></td>
+       <td tal:content="time/creator"></td>
+      </tr>
+     </table>
 
-   which displays only the allowed status to transition to.
+   I put this just above the Messages log in my issue display. Note our
+   use of the ``totalTimeSpent`` method which will total up the times
+   for the issue and return a new Interval. That will be automatically
+   displayed in the template as text like "+ 1y 2:40" (1 year, 2 hours
+   and 40 minutes).
 
+8. If you're using a persistent web server - roundup-server or
+   mod_python for example - then you'll need to restart that to pick up
+   the code changes. When that's done, you'll be able to use the new
+   time logging interface.
 
-Displaying only message summaries in the issue display
-------------------------------------------------------
 
-Alter the issue.item template section for messages to::
+Tracking different types of issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
- <table class="messages" tal:condition="context/messages">
-  <tr><th colspan="5" class="header">Messages</th></tr>
-  <tr tal:repeat="msg context/messages">
-   <td><a tal:attributes="href string:msg${msg/id}"
-          tal:content="string:msg${msg/id}"></a></td>
-   <td tal:content="msg/author">author</td>
-   <td class="date" tal:content="msg/date/pretty">date</td>
-   <td tal:content="msg/summary">summary</td>
-   <td>
-    <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">
-    remove</a>
-   </td>
-  </tr>
- </table>
+Sometimes you will want to track different types of issues - developer,
+customer support, systems, sales leads, etc. A single Roundup tracker is
+able to support multiple types of issues. This example demonstrates adding
+a customer support issue class to a tracker.
 
-Restricting the list of users that are assignable to a task
------------------------------------------------------------
+1. Figure out what information you're going to want to capture. OK, so
+   this is obvious, but sometimes it's better to actually sit down for a
+   while and think about the schema you're going to implement.
 
-1. In your tracker's "dbinit.py", create a new Role, say "Developer"::
+2. Add the new issue class to your tracker's ``dbinit.py`` - in this
+   example, we're adding a "system support" class. Just after the "issue"
+   class definition in the "open" function, add::
 
-     db.security.addRole(name='Developer', description='A developer')
+    support = IssueClass(db, "support", 
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    status=Link("status"), deadline=Date(),
+                    affects=Multilink("system"))
 
-2. Just after that, create a new Permission, say "Fixer", specific to
-   "issue"::
+3. Copy the existing "issue.*" (item, search and index) templates in the
+   tracker's "html" to "support.*". Edit them so they use the properties
+   defined in the "support" class. Be sure to check for hidden form
+   variables like "required" to make sure they have the correct set of
+   required properties.
 
-     p = db.security.addPermission(name='Fixer', klass='issue',
-         description='User is allowed to be assigned to fix issues')
+4. Edit the modules in the "detectors", adding lines to their "init"
+   functions where appropriate. Look for "audit" and "react" registrations
+   on the "issue" class, and duplicate them for "support".
 
-3. Then assign the new Permission to your "Developer" Role::
+5. Create a new sidebar box for the new support class. Duplicate the
+   existing issues one, changing the "issue" class name to "support".
 
-     db.security.addPermissionToRole('Developer', p)
+6. Re-start your tracker and start using the new "support" class.
 
-4. In the issue item edit page ("html/issue.item.html" in your tracker
-   directory), use the new Permission in restricting the "assignedto"
-   list::
 
-    <select name="assignedto">
-     <option value="-1">- no selection -</option>
-     <tal:block tal:repeat="user db/user/list">
-     <option tal:condition="python:user.hasPermission(
-                                'Fixer', context._classname)"
-             tal:attributes="
-                value user/id;
-                selected python:user.id == context.assignedto"
-             tal:content="user/realname"></option>
-     </tal:block>
-    </select>
+Optionally, you might want to restrict the users able to access this new
+class to just the users with a new "SysAdmin" Role. To do this, we add
+some security declarations::
 
-For extra security, you may wish to setup an auditor to enforce the
-Permission requirement (install this as "assignedtoFixer.py" in your
-tracker "detectors" directory)::
+    p = db.security.getPermission('View', 'support')
+    db.security.addPermissionToRole('SysAdmin', p)
+    p = db.security.getPermission('Edit', 'support')
+    db.security.addPermissionToRole('SysAdmin', p)
 
-  def assignedtoMustBeFixer(db, cl, nodeid, newvalues):
-      ''' Ensure the assignedto value in newvalues is a used with the
-          Fixer Permission
-      '''
-      if not newvalues.has_key('assignedto'):
-          # don't care
-          return
-  
-      # get the userid
-      userid = newvalues['assignedto']
-      if not db.security.hasPermission('Fixer', userid, cl.classname):
-          raise ValueError, 'You do not have permission to edit %s'%cl.classname
+You would then (as an "admin" user) edit the details of the appropriate
+users, and add "SysAdmin" to their Roles list.
 
-  def init(db):
-      db.issue.audit('set', assignedtoMustBeFixer)
-      db.issue.audit('create', assignedtoMustBeFixer)
+Alternatively, you might want to change the Edit/View permissions granted
+for the "issue" class so that it's only available to users with the "System"
+or "Developer" Role, and then the new class you're adding is available to
+all with the "User" Role.
 
-So now, if an edit action attempts to set "assignedto" to a user that
-doesn't have the "Fixer" Permission, the error will be raised.
 
+Using External User Databases
+-----------------------------
 
-Setting up a "wizard" (or "druid") for controlled adding of issues
-------------------------------------------------------------------
+Using an external password validation source
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-1. Set up the page templates you wish to use for data input. My wizard
-   is going to be a two-step process: first figuring out what category
-   of issue the user is submitting, and then getting details specific to
-   that category. The first page includes a table of help, explaining
-   what the category names mean, and then the core of the form::
+We have a centrally-managed password changing system for our users. This
+results in a UN*X passwd-style file that we use for verification of
+users. Entries in the file consist of ``name:password`` where the
+password is encrypted using the standard UN*X ``crypt()`` function (see
+the ``crypt`` module in your Python distribution). An example entry
+would be::
 
-    <form method="POST" onSubmit="return submit_once()"
-          enctype="multipart/form-data">
-      <input type="hidden" name="@template" value="add_page1">
-      <input type="hidden" name="@action" value="page1_submit">
+    admin:aamrgyQfDFSHw
 
-      <strong>Category:</strong>
-      <tal:block tal:replace="structure context/category/menu" />
-      <input type="submit" value="Continue">
-    </form>
+Each user of Roundup must still have their information stored in the Roundup
+database - we just use the passwd file to check their password. To do this, we
+need to override the standard ``verifyPassword`` method defined in
+``roundup.cgi.actions.LoginAction`` and register the new class with our
+``Client`` class in the tracker home ``interfaces.py`` module::
 
-   The next page has the usual issue entry information, with the
-   addition of the following form fragments::
+    from roundup.cgi.actions import LoginAction    
 
-    <form method="POST" onSubmit="return submit_once()"
-          enctype="multipart/form-data"
-          tal:condition="context/is_edit_ok"
-          tal:define="cat request/form/category/value">
-
-      <input type="hidden" name="@template" value="add_page2">
-      <input type="hidden" name="@required" value="title">
-      <input type="hidden" name="category" tal:attributes="value cat">
-       .
-       .
-       .
-    </form>
-
-   Note that later in the form, I test the value of "cat" include form
-   elements that are appropriate. For example::
-
-    <tal:block tal:condition="python:cat in '6 10 13 14 15 16 17'.split()">
-     <tr>
-      <th>Operating System</th>
-      <td tal:content="structure context/os/field"></td>
-     </tr>
-     <tr>
-      <th>Web Browser</th>
-      <td tal:content="structure context/browser/field"></td>
-     </tr>
-    </tal:block>
-
-   ... the above section will only be displayed if the category is one
-   of 6, 10, 13, 14, 15, 16 or 17.
-
-3. Determine what actions need to be taken between the pages - these are
-   usually to validate user choices and determine what page is next. Now encode
-   those actions in a new ``Action`` class and insert hooks to those actions in
-   the "actions" attribute on on the ``interfaces.Client`` class, like so (see 
-   `defining new web actions`_)::
-
-    class Page1SubmitAction(Action):
-        def handle(self):
-            ''' Verify that the user has selected a category, and then move
-                on to page 2.
-            '''
-            category = self.form['category'].value
-            if category == '-1':
-                self.error_message.append('You must select a category of report')
-                return
-            # everything's ok, move on to the next page
-            self.template = 'add_page2'
-
-    actions = client.Client.actions + (
-        ('page1_submit', Page1SubmitAction),
-    )
-
-4. Use the usual "new" action as the ``@action`` on the final page, and
-   you're done (the standard context/submit method can do this for you).
-
-
-Using an external password validation source
---------------------------------------------
-
-We have a centrally-managed password changing system for our users. This
-results in a UN*X passwd-style file that we use for verification of
-users. Entries in the file consist of ``name:password`` where the
-password is encrypted using the standard UN*X ``crypt()`` function (see
-the ``crypt`` module in your Python distribution). An example entry
-would be::
-
-    admin:aamrgyQfDFSHw
-
-Each user of Roundup must still have their information stored in the Roundup
-database - we just use the passwd file to check their password. To do this, we
-need to override the standard ``verifyPassword`` method defined in
-``roundup.cgi.actions.LoginAction`` and register the new class with our
-``Client`` class in the tracker home ``interfaces.py`` module::
-
-    from roundup.cgi.actions import LoginAction    
-
-    class ExternalPasswordLoginAction(LoginAction):
-        def verifyPassword(self, userid, password):
-            # get the user's username
-            username = self.db.user.get(userid, 'username')
+    class ExternalPasswordLoginAction(LoginAction):
+        def verifyPassword(self, userid, password):
+            # get the user's username
+            username = self.db.user.get(userid, 'username')
 
             # the passwords are stored in the "passwd.txt" file in the
             # tracker home
@@ -2985,205 +2948,8 @@ We also remove the redundant password fields from the ``user.item``
 template.
 
 
-Adding a "vacation" flag to users for stopping nosy messages
-------------------------------------------------------------
-
-When users go on vacation and set up vacation email bouncing, you'll
-start to see a lot of messages come back through Roundup "Fred is on
-vacation". Not very useful, and relatively easy to stop.
-
-1. add a "vacation" flag to your users::
-
-         user = Class(db, "user",
-                    username=String(),   password=Password(),
-                    address=String(),    realname=String(),
-                    phone=String(),      organisation=String(),
-                    alternate_addresses=String(),
-                    roles=String(), queries=Multilink("query"),
-                    vacation=Boolean())
-
-2. So that users may edit the vacation flags, add something like the
-   following to your ``user.item`` template::
-
-     <tr>
-      <th>On Vacation</th> 
-      <td tal:content="structure context/vacation/field">vacation</td> 
-     </tr> 
-
-3. edit your detector ``nosyreactor.py`` so that the ``nosyreaction()``
-   consists of::
-
-    def nosyreaction(db, cl, nodeid, oldvalues):
-        users = db.user
-        messages = db.msg
-        # send a copy of all new messages to the nosy list
-        for msgid in determineNewMessages(cl, nodeid, oldvalues):
-            try:
-                # figure the recipient ids
-                sendto = []
-                seen_message = {}
-                recipients = messages.get(msgid, 'recipients')
-                for recipid in messages.get(msgid, 'recipients'):
-                    seen_message[recipid] = 1
-
-                # figure the author's id, and indicate they've received
-                # the message
-                authid = messages.get(msgid, 'author')
-
-                # possibly send the message to the author, as long as
-                # they aren't anonymous
-                if (db.config.MESSAGES_TO_AUTHOR == 'yes' and
-                        users.get(authid, 'username') != 'anonymous'):
-                    sendto.append(authid)
-                seen_message[authid] = 1
-
-                # now figure the nosy people who weren't recipients
-                nosy = cl.get(nodeid, 'nosy')
-                for nosyid in nosy:
-                    # Don't send nosy mail to the anonymous user (that
-                    # user shouldn't appear in the nosy list, but just
-                    # in case they do...)
-                    if users.get(nosyid, 'username') == 'anonymous':
-                        continue
-                    # make sure they haven't seen the message already
-                    if not seen_message.has_key(nosyid):
-                        # send it to them
-                        sendto.append(nosyid)
-                        recipients.append(nosyid)
-
-                # generate a change note
-                if oldvalues:
-                    note = cl.generateChangeNote(nodeid, oldvalues)
-                else:
-                    note = cl.generateCreateNote(nodeid)
-
-                # we have new recipients
-                if sendto:
-                    # filter out the people on vacation
-                    sendto = [i for i in sendto 
-                              if not users.get(i, 'vacation', 0)]
-
-                    # map userids to addresses
-                    sendto = [users.get(i, 'address') for i in sendto]
-
-                    # update the message's recipients list
-                    messages.set(msgid, recipients=recipients)
-
-                    # send the message
-                    cl.send_message(nodeid, msgid, note, sendto)
-            except roundupdb.MessageSendError, message:
-                raise roundupdb.DetectorError, message
-
-   Note that this is the standard nosy reaction code, with the small
-   addition of::
-
-    # filter out the people on vacation
-    sendto = [i for i in sendto if not users.get(i, 'vacation', 0)]
-
-   which filters out the users that have the vacation flag set to true.
-
-
-Adding a time log to your issues
---------------------------------
-
-We want to log the dates and amount of time spent working on issues, and
-be able to give a summary of the total time spent on a particular issue.
-
-1. Add a new class to your tracker ``dbinit.py``::
-
-    # storage for time logging
-    timelog = Class(db, "timelog", period=Interval())
-
-   Note that we automatically get the date of the time log entry
-   creation through the standard property "creation".
-
-2. Link to the new class from your issue class (again, in
-   ``dbinit.py``)::
-
-    issue = IssueClass(db, "issue", 
-                    assignedto=Link("user"), topic=Multilink("keyword"),
-                    priority=Link("priority"), status=Link("status"),
-                    times=Multilink("timelog"))
-
-   the "times" property is the new link to the "timelog" class.
-
-3. We'll need to let people add in times to the issue, so in the web
-   interface we'll have a new entry field. This is a special field
-   because unlike the other fields in the issue.item template, it
-   affects a different item (a timelog item) and not the template's
-   item, an issue. We have a special syntax for form fields that affect
-   items other than the template default item (see the cgi 
-   documentation on `special form variables`_). In particular, we add a
-   field to capture a new timelog item's perdiod::
-
-    <tr> 
-     <th>Time Log</th> 
-     <td colspan=3><input type="text" name="timelog-1@period" /> 
-      <br />(enter as '3y 1m 4d 2:40:02' or parts thereof) 
-     </td> 
-    </tr> 
-         
-   and another hidden field that links that new timelog item (new
-   because it's marked as having id "-1") to the issue item. It looks
-   like this::
-
-     <input type="hidden" name="@link@times" value="timelog-1" />
-
-   On submission, the "-1" timelog item will be created and assigned a
-   real item id. The "times" property of the issue will have the new id
-   added to it.
-
-4. We want to display a total of the time log times that have been
-   accumulated for an issue. To do this, we'll need to actually write
-   some Python code, since it's beyond the scope of PageTemplates to
-   perform such calculations. We do this by adding a method to the
-   TemplatingUtils class in our tracker ``interfaces.py`` module::
-
-    class TemplatingUtils:
-        ''' Methods implemented on this class will be available to HTML
-            templates through the 'utils' variable.
-        '''
-        def totalTimeSpent(self, times):
-            ''' Call me with a list of timelog items (which have an
-                Interval "period" property)
-            '''
-            total = Interval('0d')
-            for time in times:
-                total += time.period._value
-            return total
-
-   Replace the ``pass`` line if one appears in your TemplatingUtils
-   class. As indicated in the docstrings, we will be able to access the
-   ``totalTimeSpent`` method via the ``utils`` variable in our templates.
-
-5. Display the time log for an issue::
-
-     <table class="otherinfo" tal:condition="context/times">
-      <tr><th colspan="3" class="header">Time Log
-       <tal:block
-            tal:replace="python:utils.totalTimeSpent(context.times)" />
-      </th></tr>
-      <tr><th>Date</th><th>Period</th><th>Logged By</th></tr>
-      <tr tal:repeat="time context/times">
-       <td tal:content="time/creation"></td>
-       <td tal:content="time/period"></td>
-       <td tal:content="time/creator"></td>
-      </tr>
-     </table>
-
-   I put this just above the Messages log in my issue display. Note our
-   use of the ``totalTimeSpent`` method which will total up the times
-   for the issue and return a new Interval. That will be automatically
-   displayed in the template as text like "+ 1y 2:40" (1 year, 2 hours
-   and 40 minutes).
-
-8. If you're using a persistent web server - roundup-server or
-   mod_python for example - then you'll need to restart that to pick up
-   the code changes. When that's done, you'll be able to use the new
-   time logging interface.
-
 Using a UN*X passwd file as the user database
----------------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 On some systems the primary store of users is the UN*X passwd file. It
 holds information on users such as their username, real name, password
@@ -3319,7 +3085,7 @@ And that's it!
 
 
 Using an LDAP database for user information
--------------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 A script that reads users from an LDAP store using
 http://python-ldap.sf.net/ and then compares the list to the users in the
@@ -3355,56 +3121,180 @@ So you could reimplement this as something like::
         # now verify the password supplied against the LDAP store
 
 
-Enabling display of either message summaries or the entire messages
--------------------------------------------------------------------
+Changes to Tracker Behaviour
+----------------------------
 
-This is pretty simple - all we need to do is copy the code from the
-example `displaying only message summaries in the issue display`_ into
-our template alongside the summary display, and then introduce a switch
-that shows either one or the other. We'll use a new form variable,
-``@whole_messages`` to achieve this::
+Stop "nosy" messages going to people on vacation
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
- <table class="messages" tal:condition="context/messages">
-  <tal:block tal:condition="not:request/form/@whole_messages/value | python:0">
-   <tr><th colspan="3" class="header">Messages</th>
-       <th colspan="2" class="header">
-         <a href="?@whole_messages=yes">show entire messages</a>
-       </th>
-   </tr>
-   <tr tal:repeat="msg context/messages">
-    <td><a tal:attributes="href string:msg${msg/id}"
-           tal:content="string:msg${msg/id}"></a></td>
-    <td tal:content="msg/author">author</td>
-    <td class="date" tal:content="msg/date/pretty">date</td>
-    <td tal:content="msg/summary">summary</td>
+When users go on vacation and set up vacation email bouncing, you'll
+start to see a lot of messages come back through Roundup "Fred is on
+vacation". Not very useful, and relatively easy to stop.
+
+1. add a "vacation" flag to your users::
+
+         user = Class(db, "user",
+                    username=String(),   password=Password(),
+                    address=String(),    realname=String(),
+                    phone=String(),      organisation=String(),
+                    alternate_addresses=String(),
+                    roles=String(), queries=Multilink("query"),
+                    vacation=Boolean())
+
+2. So that users may edit the vacation flags, add something like the
+   following to your ``user.item`` template::
+
+     <tr>
+      <th>On Vacation</th> 
+      <td tal:content="structure context/vacation/field">vacation</td> 
+     </tr> 
+
+3. edit your detector ``nosyreactor.py`` so that the ``nosyreaction()``
+   consists of::
+
+    def nosyreaction(db, cl, nodeid, oldvalues):
+        users = db.user
+        messages = db.msg
+        # send a copy of all new messages to the nosy list
+        for msgid in determineNewMessages(cl, nodeid, oldvalues):
+            try:
+                # figure the recipient ids
+                sendto = []
+                seen_message = {}
+                recipients = messages.get(msgid, 'recipients')
+                for recipid in messages.get(msgid, 'recipients'):
+                    seen_message[recipid] = 1
+
+                # figure the author's id, and indicate they've received
+                # the message
+                authid = messages.get(msgid, 'author')
+
+                # possibly send the message to the author, as long as
+                # they aren't anonymous
+                if (db.config.MESSAGES_TO_AUTHOR == 'yes' and
+                        users.get(authid, 'username') != 'anonymous'):
+                    sendto.append(authid)
+                seen_message[authid] = 1
+
+                # now figure the nosy people who weren't recipients
+                nosy = cl.get(nodeid, 'nosy')
+                for nosyid in nosy:
+                    # Don't send nosy mail to the anonymous user (that
+                    # user shouldn't appear in the nosy list, but just
+                    # in case they do...)
+                    if users.get(nosyid, 'username') == 'anonymous':
+                        continue
+                    # make sure they haven't seen the message already
+                    if not seen_message.has_key(nosyid):
+                        # send it to them
+                        sendto.append(nosyid)
+                        recipients.append(nosyid)
+
+                # generate a change note
+                if oldvalues:
+                    note = cl.generateChangeNote(nodeid, oldvalues)
+                else:
+                    note = cl.generateCreateNote(nodeid)
+
+                # we have new recipients
+                if sendto:
+                    # filter out the people on vacation
+                    sendto = [i for i in sendto 
+                              if not users.get(i, 'vacation', 0)]
+
+                    # map userids to addresses
+                    sendto = [users.get(i, 'address') for i in sendto]
+
+                    # update the message's recipients list
+                    messages.set(msgid, recipients=recipients)
+
+                    # send the message
+                    cl.send_message(nodeid, msgid, note, sendto)
+            except roundupdb.MessageSendError, message:
+                raise roundupdb.DetectorError, message
+
+   Note that this is the standard nosy reaction code, with the small
+   addition of::
+
+    # filter out the people on vacation
+    sendto = [i for i in sendto if not users.get(i, 'vacation', 0)]
+
+   which filters out the users that have the vacation flag set to true.
+
+Adding in state transition control
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Sometimes tracker admins want to control the states that users may move
+issues to. You can do this by following these steps:
+
+1. make "status" a required variable. This is achieved by adding the
+   following to the top of the form in the ``issue.item.html``
+   template::
+
+     <input type="hidden" name="@required" value="status">
+
+   this will force users to select a status.
+
+2. add a Multilink property to the status class::
+
+     stat = Class(db, "status", ... , transitions=Multilink('status'),
+                  ...)
+
+   and then edit the statuses already created, either:
+
+   a. through the web using the class list -> status class editor, or
+   b. using the roundup-admin "set" command.
+
+3. add an auditor module ``checktransition.py`` in your tracker's
+   ``detectors`` directory, for example::
+
+     def checktransition(db, cl, nodeid, newvalues):
+         ''' Check that the desired transition is valid for the "status"
+             property.
+         '''
+         if not newvalues.has_key('status'):
+             return
+         current = cl.get(nodeid, 'status')
+         new = newvalues['status']
+         if new == current:
+             return
+         ok = db.status.get(current, 'transitions')
+         if new not in ok:
+             raise ValueError, 'Status not allowed to move from "%s" to "%s"'%(
+                 db.status.get(current, 'name'), db.status.get(new, 'name'))
+
+     def init(db):
+         db.issue.audit('set', checktransition)
+
+4. in the ``issue.item.html`` template, change the status editing bit
+   from::
+
+    <th>Status</th>
+    <td tal:content="structure context/status/menu">status</td>
+
+   to::
+
+    <th>Status</th>
     <td>
-     <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>
+     <select tal:condition="context/id" name="status">
+      <tal:block tal:define="ok context/status/transitions"
+                 tal:repeat="state db/status/list">
+       <option tal:condition="python:state.id in ok"
+               tal:attributes="
+                    value state/id;
+                    selected python:state.id == context.status.id"
+               tal:content="state/name"></option>
+      </tal:block>
+     </select>
+     <tal:block tal:condition="not:context/id"
+                tal:replace="structure context/status/menu" />
     </td>
-   </tr>
-  </tal:block>
 
-  <tal:block tal:condition="request/form/@whole_messages/value | python:0">
-   <tr><th colspan="2" class="header">Messages</th>
-       <th class="header">
-         <a href="?@whole_messages=">show only summaries</a>
-       </th>
-   </tr>
-   <tal:block tal:repeat="msg context/messages">
-    <tr>
-     <th tal:content="msg/author">author</th>
-     <th class="date" tal:content="msg/date/pretty">date</th>
-     <th style="text-align: right">
-      (<a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>)
-     </th>
-    </tr>
-    <tr><td colspan="3" tal:content="msg/content"></td></tr>
-   </tal:block>
-  </tal:block>
- </table>
+   which displays only the allowed status to transition to.
 
 
 Blocking issues that depend on other issues
--------------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 We needed the ability to mark certain issues as "blockers" - that is,
 they can't be resolved until another issue (the blocker) they rely on is
@@ -3536,7 +3426,7 @@ history at the bottom of the issue page - look for a "link" event to
 another issue's "blockers" property.
 
 Add users to the nosy list based on the topic
----------------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 We need the ability to automatically add users to the nosy list based
 on the occurence of a topic. Every user should be allowed to edit his
@@ -3553,7 +3443,7 @@ this list of topics, and addition of an auditor which updates the nosy
 list when a topic is set.
 
 Adding the nosy topic list
-~~~~~~~~~~~~~~~~~~~~~~~~~~
+::::::::::::::::::::::::::
 
 The change in the database to make is that for any user there should be
 a list of topics for which he wants to be put on the nosy list. Adding
@@ -3573,7 +3463,7 @@ the updated definition of user will be::
                     nosy_keywords=Multilink('keyword'))
  
 Changing the user view to allow changing the nosy topic list
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
 
 We want any user to be able to change the list of topics for which
 he will by default be added to the nosy list. We choose to add this
@@ -3596,7 +3486,7 @@ E-mail addresses' in the classic template)::
   
 
 Addition of an auditor to update the nosy list
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+::::::::::::::::::::::::::::::::::::::::::::::
 
 The more difficult part is the addition of the logic to actually
 at the users to the nosy list when it is required. 
@@ -3678,7 +3568,7 @@ and these two function are the only ones needed in the file.
 TODO: update this example to use the find() Class method.
 
 Caveats
-~~~~~~~
+:::::::
 
 A few problems with the design here can be noted:
 
@@ -3702,26 +3592,68 @@ Scalability
     selected these topics a nosy topics. This will eliminate the
     loop over all users.
 
+Changes to Security and Permissions
+-----------------------------------
 
-Adding action links to the index page
--------------------------------------
+Restricting the list of users that are assignable to a task
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Add a column to the item.index.html template.
+1. In your tracker's "dbinit.py", create a new Role, say "Developer"::
 
-Resolving the issue::
+     db.security.addRole(name='Developer', description='A developer')
 
-  <a tal:attributes="href
-     string:issue${i/id}?:status=resolved&:action=edit">resolve</a>
+2. Just after that, create a new Permission, say "Fixer", specific to
+   "issue"::
 
-"Take" the issue::
+     p = db.security.addPermission(name='Fixer', klass='issue',
+         description='User is allowed to be assigned to fix issues')
 
-  <a tal:attributes="href
-     string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a>
+3. Then assign the new Permission to your "Developer" Role::
 
-... and so on
+     db.security.addPermissionToRole('Developer', p)
+
+4. In the issue item edit page ("html/issue.item.html" in your tracker
+   directory), use the new Permission in restricting the "assignedto"
+   list::
+
+    <select name="assignedto">
+     <option value="-1">- no selection -</option>
+     <tal:block tal:repeat="user db/user/list">
+     <option tal:condition="python:user.hasPermission(
+                                'Fixer', context._classname)"
+             tal:attributes="
+                value user/id;
+                selected python:user.id == context.assignedto"
+             tal:content="user/realname"></option>
+     </tal:block>
+    </select>
+
+For extra security, you may wish to setup an auditor to enforce the
+Permission requirement (install this as "assignedtoFixer.py" in your
+tracker "detectors" directory)::
+
+  def assignedtoMustBeFixer(db, cl, nodeid, newvalues):
+      ''' Ensure the assignedto value in newvalues is a used with the
+          Fixer Permission
+      '''
+      if not newvalues.has_key('assignedto'):
+          # don't care
+          return
+  
+      # get the userid
+      userid = newvalues['assignedto']
+      if not db.security.hasPermission('Fixer', userid, cl.classname):
+          raise ValueError, 'You do not have permission to edit %s'%cl.classname
+
+  def init(db):
+      db.issue.audit('set', assignedtoMustBeFixer)
+      db.issue.audit('create', assignedtoMustBeFixer)
+
+So now, if an edit action attempts to set "assignedto" to a user that
+doesn't have the "Fixer" Permission, the error will be raised.
 
 Users may only edit their issues
---------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 Users registering themselves are granted Provisional access - meaning they
 have access to edit the issues they submit, but not others. We create a new
@@ -3790,8 +3722,28 @@ template as follows::
 line).
 
 
+Changes to the Web User Interface
+---------------------------------
+
+Adding action links to the index page
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Add a column to the item.index.html template.
+
+Resolving the issue::
+
+  <a tal:attributes="href
+     string:issue${i/id}?:status=resolved&:action=edit">resolve</a>
+
+"Take" the issue::
+
+  <a tal:attributes="href
+     string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a>
+
+... and so on
+
 Colouring the rows in the issue index according to priority
------------------------------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 A simple ``tal:attributes`` statement will do the bulk of the work here. In
 the ``issue.index.html`` template, add to the ``<tr>`` that displays the
@@ -3813,7 +3765,7 @@ different priorities, like::
 and so on, with far less offensive colours :)
 
 Editing multiple items in an index view
----------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 To edit the status of all items in the item index view, edit the
 ``issue.item.html``:
@@ -3859,6 +3811,154 @@ To edit the status of all items in the item index view, edit the
    current index view parameters (filtering, columns, etc) will be used in 
    rendering the next page (the results of the editing).
 
+
+Displaying only message summaries in the issue display
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Alter the issue.item template section for messages to::
+
+ <table class="messages" tal:condition="context/messages">
+  <tr><th colspan="5" class="header">Messages</th></tr>
+  <tr tal:repeat="msg context/messages">
+   <td><a tal:attributes="href string:msg${msg/id}"
+          tal:content="string:msg${msg/id}"></a></td>
+   <td tal:content="msg/author">author</td>
+   <td class="date" tal:content="msg/date/pretty">date</td>
+   <td tal:content="msg/summary">summary</td>
+   <td>
+    <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">
+    remove</a>
+   </td>
+  </tr>
+ </table>
+
+
+Enabling display of either message summaries or the entire messages
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This is pretty simple - all we need to do is copy the code from the
+example `displaying only message summaries in the issue display`_ into
+our template alongside the summary display, and then introduce a switch
+that shows either one or the other. We'll use a new form variable,
+``@whole_messages`` to achieve this::
+
+ <table class="messages" tal:condition="context/messages">
+  <tal:block tal:condition="not:request/form/@whole_messages/value | python:0">
+   <tr><th colspan="3" class="header">Messages</th>
+       <th colspan="2" class="header">
+         <a href="?@whole_messages=yes">show entire messages</a>
+       </th>
+   </tr>
+   <tr tal:repeat="msg context/messages">
+    <td><a tal:attributes="href string:msg${msg/id}"
+           tal:content="string:msg${msg/id}"></a></td>
+    <td tal:content="msg/author">author</td>
+    <td class="date" tal:content="msg/date/pretty">date</td>
+    <td tal:content="msg/summary">summary</td>
+    <td>
+     <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>
+    </td>
+   </tr>
+  </tal:block>
+
+  <tal:block tal:condition="request/form/@whole_messages/value | python:0">
+   <tr><th colspan="2" class="header">Messages</th>
+       <th class="header">
+         <a href="?@whole_messages=">show only summaries</a>
+       </th>
+   </tr>
+   <tal:block tal:repeat="msg context/messages">
+    <tr>
+     <th tal:content="msg/author">author</th>
+     <th class="date" tal:content="msg/date/pretty">date</th>
+     <th style="text-align: right">
+      (<a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>)
+     </th>
+    </tr>
+    <tr><td colspan="3" tal:content="msg/content"></td></tr>
+   </tal:block>
+  </tal:block>
+ </table>
+
+Setting up a "wizard" (or "druid") for controlled adding of issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. Set up the page templates you wish to use for data input. My wizard
+   is going to be a two-step process: first figuring out what category
+   of issue the user is submitting, and then getting details specific to
+   that category. The first page includes a table of help, explaining
+   what the category names mean, and then the core of the form::
+
+    <form method="POST" onSubmit="return submit_once()"
+          enctype="multipart/form-data">
+      <input type="hidden" name="@template" value="add_page1">
+      <input type="hidden" name="@action" value="page1_submit">
+
+      <strong>Category:</strong>
+      <tal:block tal:replace="structure context/category/menu" />
+      <input type="submit" value="Continue">
+    </form>
+
+   The next page has the usual issue entry information, with the
+   addition of the following form fragments::
+
+    <form method="POST" onSubmit="return submit_once()"
+          enctype="multipart/form-data"
+          tal:condition="context/is_edit_ok"
+          tal:define="cat request/form/category/value">
+
+      <input type="hidden" name="@template" value="add_page2">
+      <input type="hidden" name="@required" value="title">
+      <input type="hidden" name="category" tal:attributes="value cat">
+       .
+       .
+       .
+    </form>
+
+   Note that later in the form, I test the value of "cat" include form
+   elements that are appropriate. For example::
+
+    <tal:block tal:condition="python:cat in '6 10 13 14 15 16 17'.split()">
+     <tr>
+      <th>Operating System</th>
+      <td tal:content="structure context/os/field"></td>
+     </tr>
+     <tr>
+      <th>Web Browser</th>
+      <td tal:content="structure context/browser/field"></td>
+     </tr>
+    </tal:block>
+
+   ... the above section will only be displayed if the category is one
+   of 6, 10, 13, 14, 15, 16 or 17.
+
+3. Determine what actions need to be taken between the pages - these are
+   usually to validate user choices and determine what page is next. Now encode
+   those actions in a new ``Action`` class and insert hooks to those actions in
+   the "actions" attribute on on the ``interfaces.Client`` class, like so (see 
+   `defining new web actions`_)::
+
+    class Page1SubmitAction(Action):
+        def handle(self):
+            ''' Verify that the user has selected a category, and then move
+                on to page 2.
+            '''
+            category = self.form['category'].value
+            if category == '-1':
+                self.error_message.append('You must select a category of report')
+                return
+            # everything's ok, move on to the next page
+            self.template = 'add_page2'
+
+    actions = client.Client.actions + (
+        ('page1_submit', Page1SubmitAction),
+    )
+
+4. Use the usual "new" action as the ``@action`` on the final page, and
+   you're done (the standard context/submit method can do this for you).
+
+
+
 -------------------
 
 Back to `Table of Contents`_