Code

- added a favicon
[roundup.git] / doc / customizing.txt
index 7142592a35a0f360f1aee9d7f601ff18710e67c7..243eb0335ffdabce601a7231826244bd6a96df2f 100644 (file)
@@ -2,7 +2,7 @@
 Customising Roundup
 ===================
 
-:Version: $Revision: 1.98 $
+: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
@@ -16,13 +16,13 @@ What You Can Do
 Before you get too far, it's probably worth having a quick read of the Roundup
 `design documentation`_.
 
-Customisation of Roundup can take one of five forms:
+Customisation of Roundup can take one of six forms:
 
 1. `tracker configuration`_ file changes
 2. database, or `tracker schema`_ changes
 3. "definition" class `database content`_ changes
 4. behavioural changes, through detectors_
-5. `access controls`_
+5. `security / access controls`_
 6. change the `web interface`_
 
 The third case is special because it takes two distinctly different forms
@@ -146,6 +146,16 @@ The configuration variables available are:
 
     "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
 
+**ERROR_MESSAGES_TO** - ``'user'``, ``'dispatcher'`` or ``'both'``
+ Sends error messages to the dispatcher, user, or both. It will use the
+ ``DISPATCHER_EMAIL`` address if set, otherwise it'll use the
+ ``ADMIN_EMAIL`` address.
+
+**DISPATCHER_EMAIL** - ``''``
+  The email address that Roundup will issue all error messages to. This is
+  also useful if you want to switch your 'new message' notification to
+  a central user. 
+
 **MESSAGES_TO_AUTHOR** - ``'new'``, ``'yes'`` or``'no'``
  Send nosy messages to the author of the message?
  If 'new' is used, then the author will only be sent the message when the
@@ -169,17 +179,35 @@ The configuration variables available are:
 
 **EMAIL_KEEP_QUOTED_TEXT** - ``'yes'`` or ``'no'``
  Keep email citations. Citations are the part of e-mail which the sender has
- quoted in their reply to previous e-mail.
+ quoted in their reply to previous e-mail with ``>`` or ``|`` characters at
+ the start of the line.
 
 **EMAIL_LEAVE_BODY_UNCHANGED** - ``'no'``
  Preserve the email body as is. Enabiling this will cause the entire message
- body to be stored, including all citations and signatures. It should be
- either ``'yes'`` or ``'no'``.
+ body to be stored, including all citations, signatures and Outlook-quoted
+ sections (ie. "Original Message" blocks). It should be either ``'yes'``
+ or ``'no'``.
 
 **MAIL_DEFAULT_CLASS** - ``'issue'`` or ``''``
  Default class to use in the mailgw if one isn't supplied in email
  subjects. To disable, comment out the variable below or leave it blank.
 
+**HTML_VERSION** -  ``'html4'`` or ``'xhtml'``
+ HTML version to generate. The templates are html4 by default. If you
+ wish to make them xhtml, then you'll need to change this var to 'xhtml'
+ too so all auto-generated HTML is compliant.
+
+**EMAIL_CHARSET** - ``utf-8`` (or ``iso-8859-1`` for Eudora users)
+ Character set to encode email headers with. We use utf-8 by default, as
+ it's the most flexible. Some mail readers (eg. Eudora) can't cope with
+ that, so you might need to specify a more limited character set (eg.
+ 'iso-8859-1'.
+
+**DEFAULT_TIMEZONE** - ``0``
+ Numeric hour timezone offest to be used when displaying local times.
+ The default timezone is used when users do not choose their own in
+ their settings.
+
 The default config.py is given below - as you
 can see, the MAIL_DOMAIN must be edited before any interaction with the
 tracker is attempted.::
@@ -255,6 +283,22 @@ tracker is attempted.::
     MAIL_DEFAULT_CLASS = 'issue'   # use "issue" class by default
     #MAIL_DEFAULT_CLASS = ''        # disable (or just comment the var out)
 
+    # HTML version to generate. The templates are html4 by default. If you
+    # wish to make them xhtml, then you'll need to change this var to 'xhtml'
+    # too so all auto-generated HTML is compliant.
+    HTML_VERSION = 'html4'         # either 'html4' or 'xhtml'
+
+    # Character set to encode email headers with. We use utf-8 by default, as
+    # it's the most flexible. Some mail readers (eg. Eudora) can't cope with
+    # that, so you might need to specify a more limited character set (eg.
+    # 'iso-8859-1'.
+    EMAIL_CHARSET = 'utf-8'
+    #EMAIL_CHARSET = 'iso-8859-1'   # use this instead for Eudora users
+
+    # You may specify a different default timezone, for use when users do not
+    # choose their own in their settings.
+    DEFAULT_TIMEZONE = 0            # specify as numeric hour offest
+
     # 
     # SECURITY DEFINITIONS
     #
@@ -273,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())
@@ -301,6 +368,41 @@ of ``'setkey'``)::
         priority=Link("priority"))
     issue.setkey('title')
 
+
+What you can't do to the schema
+-------------------------------
+
+You must never:
+
+**Remove the users class**
+  This class is the only *required* class in Roundup. Similarly, its
+  username, password and address properties must never be removed.
+
+**Change the type of a property**
+  Property types must *never* be changed - the database simply doesn't take
+  this kind of action into account. Note that you can't just remove a
+  property and re-add it as a new type either. If you wanted to make the
+  assignedto property a Multilink, you'd need to create a new property
+  assignedto_list and remove the old assignedto property.
+
+
+What you can do to the schema
+-----------------------------
+
+Your schema may be changed at any time before or after the tracker has been
+initialised (or used). You may:
+
+**Add new properties to classes, or add whole new classes**
+  This is painless and easy to do - there are generally no repurcussions
+  from adding new information to a tracker's schema.
+
+**Remove properties**
+  Removing properties is a little more tricky - you need to make sure that
+  the property is no longer used in the `web interface`_ *or* by the
+  detectors_.
+
+
+
 Classes and Properties - creating a new information store
 ---------------------------------------------------------
 
@@ -516,6 +618,50 @@ to use one, copy it to the ``'detectors'`` of your tracker instance:
         db.issue.react('create', newissuecopy)
 
 
+Auditor or Reactor?
+-------------------
+
+Generally speaking, the following rules should be observed:
+
+**Auditors**
+  Are used for `vetoing creation of or changes to items`_. They might
+  also make automatic changes to item properties.
+**Reactors**
+  Detect changes in the database and react accordingly. They should avoid
+  making changes to the database where possible, as this could create
+  detector loops.
+
+
+Vetoing creation of or changes to items
+---------------------------------------
+
+Auditors may raise the ``Reject`` exception to prevent the creation of
+or changes to items in the database.  The mail gateway, for example, will
+not attach files or messages to issues when the creation of those files or
+messages are prevented through the ``Reject`` exception. It'll also not create
+users if that creation is ``Reject``'ed too.
+
+To use, simply add at the top of your auditor::
+
+   from roundup.exceptions import Reject
+
+And then when your rejection criteria have been detected, simply::
+
+   raise Reject
+
+
+Generating email from Roundup
+-----------------------------
+
+The module ``roundup.mailer`` contains most of the nuts-n-bolts required
+to generate email messages from Roundup.
+
+In addition, the ``IssueClass`` methods ``nosymessage()`` and
+``send_message()`` are used to generate nosy messages, and may generate
+messages which only consist of a change note (ie. the message id parameter
+is not required).
+
+
 Database Content
 ================
 
@@ -545,14 +691,17 @@ See "`adding a new field to the classic schema`_" for an example that
 requires database content changes.
 
 
-Access Controls
-===============
+Security / Access Controls
+==========================
 
 A set of Permissions is built into the security module by default:
 
 - Edit (everything)
 - View (everything)
 
+Every Class you define in your tracker's schema also gets an Edit and View
+Permission of its own.
+
 The default interfaces define:
 
 - Web Registration
@@ -583,13 +732,6 @@ settings appear in the ``open()`` function of the tracker ``dbinit.py``
     #
     # SECURITY SETTINGS
     #
-    # new permissions for this schema
-    for cl in ('user', ):
-        db.security.addPermission(name="Edit", klass=cl,
-            description="User is allowed to edit "+cl)
-        db.security.addPermission(name="View", klass=cl,
-            description="User is allowed to access "+cl)
-
     # and give the regular users access to the web and email interface
     p = db.security.getPermission('Web Access')
     db.security.addPermissionToRole('User', p)
@@ -637,7 +779,13 @@ Adding a new Permission
 
 When adding a new Permission, you will need to:
 
-1. add it to your tracker's dbinit so it is created
+1. add it to your tracker's dbinit so it is created, using
+   ``security.addPermission``, for example::
+
+    self.security.addPermission(name="View", klass='frozzle',
+        description="User is allowed to access frozzles")
+
+   will set up a new "View" permission on the Class "frozzle".
 2. enable it for the Roles that should have it (verify with
    "``roundup-admin security``")
 3. add it to the relevant HTML interface templates
@@ -706,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``
@@ -764,18 +911,18 @@ identifier is examined. Typical URL paths look like:
 
 1.  ``/tracker/issue``
 2.  ``/tracker/issue1``
-3.  ``/tracker/_file/style.css``
+3.  ``/tracker/@file/style.css``
 4.  ``/cgi-bin/roundup.cgi/tracker/file1``
 5.  ``/cgi-bin/roundup.cgi/tracker/file1/kitten.png``
 
 where the "tracker identifier" is "tracker" in the above cases. That means
-we're looking at "issue", "issue1", "_file/style.css", "file1" and
+we're looking at "issue", "issue1", "@file/style.css", "file1" and
 "file1/kitten.png" in the cases above. The path is generally only one
 entry long - longer paths are handled differently.
 
 a. if there is no path, then we are in the "home" context.
-b. if the path starts with "_file" (as in example 3,
-   "/tracker/_file/style.css"), then the additional path entry,
+b. if the path starts with "@file" (as in example 3,
+   "/tracker/@file/style.css"), then the additional path entry,
    "style.css" specifies the filename of a static file we're to serve up
    from the tracker "html" directory. Raises a SendStaticFile exception.
 c. if there is something in the path (as in example 1, "issue"), it
@@ -799,6 +946,13 @@ defaults to:
 - full item designator supplied: "item"
 
 
+Serving static content
+----------------------
+
+See the previous section `determining web context`_ where it describes
+``@file`` paths.
+
+
 Performing actions in web requests
 ----------------------------------
 
@@ -845,15 +999,15 @@ of:
  - Also handle the ":queryname" variable and save off the query to the
    user's query list.
 
-Each of the actions is implemented by a corresponding ``*actionAction*``
-(where "action" is the name of the action) method on the
-``roundup.cgi.Client`` class, which also happens to be available in your
-tracker instance as ``interfaces.Client``. So if you need to define new
-actions, you may add them there (see `defining new web actions`_).
+Each of the actions is implemented by a corresponding ``*XxxAction*`` (where
+"Xxx" is the name of the action) class in the ``roundup.cgi.actions`` module.
+These classes are registered with ``roundup.cgi.client.Client`` which also
+happens to be available in your tracker instance as ``interfaces.Client``. So
+if you need to define new actions, you may add them there (see `defining new
+web actions`_).
 
-Each action also has a corresponding ``*actionPermission*`` (where
-"action" is the name of the action) method which determines whether the
-action is permissible given the current user. The base permission checks
+Each action class also has a ``*permission*`` method which determines whether
+the action is permissible given the current user. The base permission checks
 are:
 
 **login**
@@ -988,7 +1142,7 @@ Two special form values are supported for backwards compatibility:
     This is equivalent to::
 
         @link@messages=msg-1
-        @msg-1@content=value
+        msg-1@content=value
 
     except that in addition, the "author" and "date" properties of
     "msg-1" are set to the userid of the submitter, and the current
@@ -998,7 +1152,7 @@ Two special form values are supported for backwards compatibility:
     This is equivalent to::
 
         @link@files=file-1
-        @file-1@content=value
+        file-1@content=value
 
     The String content value is handled as described above for file
     uploads.
@@ -1019,6 +1173,10 @@ returning.
 Default templates
 -----------------
 
+The default templates are html4 compliant. If you wish to change them to be
+xhtml compliant, you'll need to change the ``HTML_VERSION`` configuration
+variable in ``config.py`` to ``'xhtml'`` instead of ``'html4'``.
+
 Most customisation of the web view can be done by modifying the
 templates in the tracker ``'html'`` directory. There are several types
 of files in there. The *minimal* template includes:
@@ -1443,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
@@ -1536,15 +1697,39 @@ now         only on Date properties - return the current date as a new
 reldate     only on Date properties - render the interval between the date
             and now
 local       only on Date properties - return this date as a new property
-            with some timezone offset
-pretty      only on Interval properties - render the interval in a pretty
+            with some timezone offset, for example::
+            
+                python:context.creation.local(10)
+
+            will render the date with a +10 hour offset.
+pretty      Date properties - render the date as "dd Mon YYYY" (eg. "19
+            Mar 2004"). Takes an optional format argument, for example::
+
+                python:context.activity.pretty('%Y-%m-%d')
+
+            Will format as "2004-03-19" instead.
+
+            Interval properties - render the interval in a pretty
             format (eg. "yesterday")
 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
+display or edit the data they are manipulating. The simplest case is
+editing an issue title. Including the expression::
+
+   context/title/field
+
+Will present the user with an edit field, if they have edit permission. If
+not, then they will be presented with a static display if they have view
+permission. If they don't even have view permission, then an error message
+is raised, preventing the display of the page, indicating that they don't
+have permission to view the information.
+
 
 The request variable
 ~~~~~~~~~~~~~~~~~~~~
@@ -1680,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
@@ -1866,19 +2053,19 @@ template)::
 
  <table class="form">
  <tr>
-  <th nowrap>Title</th>
+  <th>Title</th>
   <td colspan="3" tal:content="structure python:context.title.field(size=60)">title</td>
  </tr>
  
  <tr>
-  <th nowrap>Priority</th>
+  <th>Priority</th>
   <td tal:content="structure context/priority/menu">priority</td>
-  <th nowrap>Status</th>
+  <th>Status</th>
   <td tal:content="structure context/status/menu">status</td>
  </tr>
  
  <tr>
-  <th nowrap>Superseder</th>
+  <th>Superseder</th>
   <td>
    <span tal:replace="structure python:context.superseder.field(showid=1, size=20)" />
    <span tal:replace="structure python:db.issue.classhelp('id,title')" />
@@ -1886,7 +2073,7 @@ template)::
     <br>View: <span tal:replace="structure python:context.superseder.link(showid=1)" />
    </span>
   </td>
-  <th nowrap>Nosy List</th>
+  <th>Nosy List</th>
   <td>
    <span tal:replace="structure context/nosy/field" />
    <span tal:replace="structure python:db.user.classhelp('username,realname,address,phone')" />
@@ -1894,7 +2081,7 @@ template)::
  </tr>
  
  <tr>
-  <th nowrap>Assigned To</th>
+  <th>Assigned To</th>
   <td tal:content="structure context/assignedto/menu">
    assignedto menu
   </td>
@@ -1903,14 +2090,14 @@ template)::
  </tr>
  
  <tr>
-  <th nowrap>Change Note</th>
+  <th>Change Note</th>
   <td colspan="3">
    <textarea name=":note" wrap="hard" rows="5" cols="60"></textarea>
   </td>
  </tr>
  
  <tr>
-  <th nowrap>File</th>
+  <th>File</th>
   <td colspan="3"><input type="file" name=":file" size="40"></td>
  </tr>
  
@@ -2022,12 +2209,12 @@ templating through the "journal" method of the item*::
 Defining new web actions
 ------------------------
 
-You may define new actions to be triggered by the ``@action`` form
-variable. These are added to the tracker ``interfaces.py`` as methods on
-the ``Client`` class. 
+You may define new actions to be triggered by the ``@action`` form variable.
+These are added to the tracker ``interfaces.py`` as ``Action`` classes that get
+called by the the ``Client`` class.
 
-Adding action methods takes three steps; first you `define the new
-action method`_, then you `register the action method`_ with the cgi
+Adding action classes takes three steps; first you `define the new
+action class`_, then you `register the action class`_ with the cgi
 interface so it may be triggered by the ``@action`` form variable.
 Finally you `use the new action`_ in your HTML form.
 
@@ -2035,41 +2222,41 @@ See "`setting up a "wizard" (or "druid") for controlled adding of
 issues`_" for an example.
 
 
-Define the new action method
+Define the new action class
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-The action methods have the following interface::
+The action classes have the following interface::
 
-    def myActionMethod(self):
-        ''' Perform some action. No return value is required.
-        '''
+ class MyAction(Action):
+     def handle(self):
+         ''' Perform some action. No return value is required.
+         '''
 
-The *self* argument is an instance of your tracker ``instance.Client``
-class - thus it's mostly implemented by ``roundup.cgi.Client``. See the
+The *self.client* attribute is an instance of your tracker ``instance.Client``
+class - thus it's mostly implemented by ``roundup.cgi.client.Client``. See the
 docstring of that class for details of what it can do.
 
 The method will typically check the ``self.form`` variable's contents.
 It may then:
 
-- add information to ``self.ok_message`` or ``self.error_message``
-- change the ``self.template`` variable to alter what the user will see
+- add information to ``self.client.ok_message`` or ``self.client.error_message``
+- change the ``self.client.template`` variable to alter what the user will see
   next
 - raise Unauthorised, SendStaticFile, SendFile, NotFound or Redirect
-  exceptions
+  exceptions (import them from roundup.cgi.exceptions)
 
 
-Register the action method
+Register the action class
 ~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-The method is now written, but isn't available to the user until you add
-it to the `instance.Client`` class ``actions`` variable, like so::
+The class is now written, but isn't available to the user until you add it to
+the ``instance.Client`` class ``actions`` variable, like so::
 
-    actions = client.Class.actions + (
-        ('myaction', 'myActionMethod'),
+    actions = client.Client.actions + (
+        ('myaction', myActionClass),
     )
 
-This maps the action name "myaction" to the action method we defined.
-
+This maps the action name "myaction" to the action class we defined.
 
 Use the new action
 ~~~~~~~~~~~~~~~~~~
@@ -2080,24 +2267,48 @@ In your HTML form, add a hidden form element like so::
 
 where "myaction" is the name you registered in the previous step.
 
+Actions may return content to the user
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Actions generally perform some database manipulation and then pass control
+on to the rendering of a template in the current context (see `Determining
+web context`_ for how that works.) Some actions will want to generate the
+actual content returned to the user. Action methods may return their own
+content string to be displayed to the user, overriding the templating step.
+In this situation, we assume that the content is HTML by default. You may
+override the content type indicated to the user by calling ``setHeader``::
+
+   self.client.setHeader('Content-Type', 'text/csv')
+
+This example indicates that the value sent back to the user is actually
+comma-separated value content (eg. something to be loaded into a
+spreadsheet or database).
+
 
 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
@@ -2110,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
@@ -2152,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
@@ -2183,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
@@ -2241,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
@@ -2283,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
@@ -2341,7 +2551,7 @@ will be the "name" variable of the current context (which is
 to the form, a new category will be created with that name::
 
     <tr>
-     <th nowrap>Name</th>
+     <th>Name</th>
      <td tal:content="structure python:context.name.field(size=60)">
      name</td>
     </tr>
@@ -2373,19 +2583,20 @@ So putting it all together, and closing the table and form we get::
    <form method="POST" onSubmit="return submit_once()"
          enctype="multipart/form-data">
 
-    <input type="hidden" name="@required" value="name">
-
     <table class="form">
      <tr><th class="header" colspan="2">Category</th></tr>
 
      <tr>
-      <th nowrap>Name</th>
+      <th>Name</th>
       <td tal:content="structure python:context.name.field(size=60)">
       name</td>
      </tr>
 
      <tr>
-      <td>&nbsp;</td>
+      <td>
+        &nbsp;
+        <input type="hidden" name="@required" value="name"> 
+      </td>
       <td colspan="3" tal:content="structure context/submit">
        submit button will go here
       </td>
@@ -2402,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
@@ -2414,7 +2625,7 @@ Just like ``category.issue.html`` this file defines a form which has a
 table to lay things out. It doesn't matter where in the table we add new
 stuff, it is entirely up to your sense of aesthetics::
 
-   <th nowrap>Category</th>
+   <th>Category</th>
    <td><span tal:replace="structure context/category/field" />
        <span tal:replace="structure db/category/classhelp" />
    </td>
@@ -2430,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
@@ -2488,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;
@@ -2529,236 +2740,166 @@ 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 nowrap>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 nowrap>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 nowrap 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)::
-
-  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
+    p = db.security.getPermission('View', 'support')
+    db.security.addPermissionToRole('SysAdmin', p)
+    p = db.security.getPermission('Edit', 'support')
+    db.security.addPermissionToRole('SysAdmin', p)
 
-  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.
+You would then (as an "admin" user) edit the details of the appropriate
+users, and add "SysAdmin" to their Roles list.
 
+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.
 
-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="page1submit">
-
-      <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 nowrap>Operating System</th>
-      <td tal:content="structure context/os/field"></td>
-     </tr>
-     <tr>
-      <th nowrap>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 methods on the ``interfaces.Client`` class
-   and insert hooks to those actions in the "actions" attribute on that
-   class, like so::
-
-    actions = client.Client.actions + (
-        ('page1_submit', 'page1SubmitAction'),
-    )
-
-    def page1SubmitAction(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'
-
-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 External User Databases
+-----------------------------
 
 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
@@ -2769,27 +2910,36 @@ 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 add the following code to our ``Client`` class in the
-tracker home ``interfaces.py`` module::
+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::
 
-    def verifyPassword(self, userid, password):
-        # get the user's username
-        username = self.db.user.get(userid, 'username')
+    from roundup.cgi.actions import LoginAction    
 
-        # the passwords are stored in the "passwd.txt" file in the
-        # tracker home
-        file = os.path.join(self.db.config.TRACKER_HOME, 'passwd.txt')
+    class ExternalPasswordLoginAction(LoginAction):
+        def verifyPassword(self, userid, password):
+            # get the user's username
+            username = self.db.user.get(userid, 'username')
 
-        # see if we can find a match
-        for ent in [line.strip().split(':') for line in
-                                            open(file).readlines()]:
-            if ent[0] == username:
-                return crypt.crypt(password, ent[1][:2]) == ent[1]
+            # the passwords are stored in the "passwd.txt" file in the
+            # tracker home
+            file = os.path.join(self.db.config.TRACKER_HOME, 'passwd.txt')
 
-        # user doesn't exist in the file
-        return 0
+            # see if we can find a match
+            for ent in [line.strip().split(':') for line in
+                                                open(file).readlines()]:
+                if ent[0] == username:
+                    return crypt.crypt(password, ent[1][:2]) == ent[1]
+
+            # user doesn't exist in the file
+            return 0
+
+    class Client(client.Client):
+        actions = client.Client.actions + (
+            ('login', ExternalPasswordLoginAction)
+        )
 
 What this does is look through the file, line by line, looking for a
 name that matches.
@@ -2798,207 +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):
-        # send a copy of all new messages to the nosy list
-        for msgid in determineNewMessages(cl, nodeid, oldvalues):
-            try:
-                users = db.user
-                messages = db.msg
-
-                # figure the recipient ids
-                sendto = []
-                r = {}
-                recipients = messages.get(msgid, 'recipients')
-                for recipid in messages.get(msgid, 'recipients'):
-                    r[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)
-                r[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 r.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 nowrap>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('')
-            for time in times:
-                total += time.period._value
-            return total
-
-   Replace the ``pass`` line as we did in step 4 above with the Client
-   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
@@ -3134,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
@@ -3145,9 +3096,10 @@ for more information about doing this.
 
 To authenticate off the LDAP store (rather than using the passwords in the
 roundup user database) you'd use the same python-ldap module inside an
-extension to the cgi interface. You'd do this by adding a method called
-"verifyPassword" to the Client class in your tracker's interfaces.py
-module. The method is implemented by default as::
+extension to the cgi interface. You'd do this by overriding the method called
+"verifyPassword" on the LoginAction class in your tracker's interfaces.py
+module (see `using an external password validation source`_). The method is
+implemented by default as::
 
     def verifyPassword(self, userid, password):
         ''' Verify the password that the user has supplied
@@ -3169,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 nowrap 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 nowrap 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
@@ -3242,7 +3318,7 @@ resolved. To achieve this:
 2. Add the new "blockers" property to the issue.item edit page, using
    something like::
 
-    <th nowrap>Waiting On</th>
+    <th>Waiting On</th>
     <td>
      <span tal:replace="structure python:context.blockers.field(showid=1,
                                   size=20)" />
@@ -3350,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
@@ -3367,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
@@ -3387,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
@@ -3410,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. 
@@ -3492,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:
 
@@ -3516,6 +3592,373 @@ Scalability
     selected these topics a nosy topics. This will eliminate the
     loop over all users.
 
+Changes to Security and Permissions
+-----------------------------------
+
+Restricting the list of users that are assignable to a task
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. In your tracker's "dbinit.py", create a new Role, say "Developer"::
+
+     db.security.addRole(name='Developer', description='A developer')
+
+2. Just after that, create a new Permission, say "Fixer", specific to
+   "issue"::
+
+     p = db.security.addPermission(name='Fixer', klass='issue',
+         description='User is allowed to be assigned to fix issues')
+
+3. Then assign the new Permission to your "Developer" Role::
+
+     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
+Role called "Provisional User" which is granted to newly-registered users,
+and has limited access. One of the Permissions they have is the new "Edit
+Own" on issues (regular users have "Edit".) We back up the permissions with
+an auditor.
+
+First up, we create the new Role and Permission structure in
+``dbinit.py``::
+
+    # New users not approved by the admin
+    db.security.addRole(name='Provisional User',
+        description='New user registered via web or email')
+    p = db.security.addPermission(name='Edit Own', klass='issue',
+        description='Can only edit own issues')
+    db.security.addPermissionToRole('Provisional User', p)
+
+    # Assign the access and edit Permissions for issue to new users now
+    p = db.security.getPermission('View', 'issue')
+    db.security.addPermissionToRole('Provisional User', p)
+    p = db.security.getPermission('Edit', 'issue')
+    db.security.addPermissionToRole('Provisional User', p)
+
+    # and give the new users access to the web and email interface
+    p = db.security.getPermission('Web Access')
+    db.security.addPermissionToRole('Provisional User', p)
+    p = db.security.getPermission('Email Access')
+    db.security.addPermissionToRole('Provisional User', p)
+
+
+Then in the ``config.py`` we change the Role assigned to newly-registered
+users, replacing the existing ``'User'`` values::
+
+    NEW_WEB_USER_ROLES = 'Provisional User'
+    NEW_EMAIL_USER_ROLES = 'Provisional User'
+
+Finally we add a new *auditor* to the ``detectors`` directory called
+``provisional_user_auditor.py``::
+
+ def audit_provisionaluser(db, cl, nodeid, newvalues):
+     ''' New users are only allowed to modify their own issues.
+     '''
+     if (db.getuid() != cl.get(nodeid, 'creator')
+         and db.security.hasPermission('Edit Own', db.getuid(), cl.classname)):
+         raise ValueError, ('You are only allowed to edit your own %s'
+                            % cl.classname)
+
+ def init(db):
+     # fire before changes are made
+     db.issue.audit('set', audit_provisionaluser)
+     db.issue.audit('retire', audit_provisionaluser)
+     db.issue.audit('restore', audit_provisionaluser)
+
+Note that some older trackers might also want to change the ``page.html``
+template as follows::
+
+ <p class="classblock"
+ -       tal:condition="python:request.user.username != 'anonymous'">
+ +       tal:condition="python:request.user.hasPermission('View', 'user')">
+     <b>Administration</b><br>
+     <tal:block tal:condition="python:request.user.hasPermission('Edit', None)">
+      <a href="home?:template=classlist">Class List</a><br>
+
+(note that the "-" indicates a removed line, and the "+" indicates an added
+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
+actual rows of data::
+
+   <tr tal:attributes="class string:priority-${i/priority/plain}">
+
+and then in your stylesheet (``style.css``) specify the colouring for the
+different priorities, like::
+
+   tr.priority-critical td {
+       background-color: red;
+   }
+
+   tr.priority-urgent td {
+       background-color: orange;
+   }
+
+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``:
+
+1. add a form around the listing table, so at the top it reads::
+
+    <form method="POST" tal:attributes="action request/classname">
+     <table class="list">
+
+   and at the bottom of that table::
+
+     </table>
+    </form
+
+   making sure you match the ``</table>`` from the list table, not the
+   navigation table or the subsequent form table.
+
+2. in the display for the issue property, change::
+
+    <td tal:condition="request/show/status"
+        tal:content="python:i.status.plain() or default">&nbsp;</td>
+
+   to::
+
+    <td tal:condition="request/show/status"
+        tal:content="structure i/status/field">&nbsp;</td>
+
+   this will result in an edit field for the status property.
+
+3. after the ``tal:block`` which lists the actual index items (marked by
+   ``tal:repeat="i batch"``) add a new table row::
+
+    <tr>
+     <td tal:attributes="colspan python:len(request.columns)">
+      <input type="submit" value=" Save Changes ">
+      <input type="hidden" name="@action" value="edit">
+      <tal:block replace="structure request/indexargs_form" />
+     </td>
+    </tr>
+
+   which gives us a submit button, indicates that we are performing an edit
+   on any changed statuses and the final block will make sure that 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`_