diff doc/customizing.txt @ 2156:d68eeb9d363f

reorg of the (now quite long) examples section and add new example
author Richard Jones <richard@users.sourceforge.net>
date Sun, 28 Mar 2004 23:46:10 +0000
parents 9f6e6bc40a08
children c22329f379ae
line wrap: on
line diff
--- a/doc/customizing.txt	Sat Mar 27 04:00:10 2004 +0000
+++ b/doc/customizing.txt	Sun Mar 28 23:46:10 2004 +0000
@@ -2,7 +2,7 @@
 Customising Roundup
 ===================
 
-:Version: $Revision: 1.128 $
+:Version: $Revision: 1.129 $
 
 .. This document borrows from the ZopeBook section on ZPT. The original is at:
    http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx
@@ -854,7 +854,6 @@
 
 .. 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``
@@ -2286,18 +2285,25 @@
 
 .. 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
@@ -2310,7 +2316,7 @@
 
 
 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
@@ -2352,7 +2358,7 @@
 
 
 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
@@ -2383,12 +2389,11 @@
      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
@@ -2441,7 +2446,7 @@
 
 
 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
@@ -2483,7 +2488,7 @@
 
 
 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
@@ -2603,7 +2608,7 @@
 
 
 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
@@ -2631,7 +2636,7 @@
 
 
 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
@@ -2689,7 +2694,7 @@
   </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;
@@ -2730,385 +2735,8 @@
 tells roundup which fields of the issue to display. Simply add
 "category" to that list and it all should work.
 
-
-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>
-     <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>
-
-   which displays only the allowed status to transition to.
-
-
-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>
-
-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.
-
-
-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).
-
-
-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')
-
-            # the passwords are stored in the "passwd.txt" file in the
-            # tracker home
-            file = os.path.join(self.db.config.TRACKER_HOME, 'passwd.txt')
-
-            # 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.
-
-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.
@@ -3206,8 +2834,111 @@
    the code changes. When that's done, you'll be able to use the new
    time logging interface.
 
+
+Tracking different types of issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Sometimes you will want to track different types of issues - developer,
+customer support, systems, sales leads, etc. A single Rounup tracker is
+able to support multiple types of issues. This example demonstrates adding
+a customer support issue class to a tracker.
+
+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.
+
+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::
+
+    support = IssueClass(db, "support", 
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    status=Link("status"), deadline=Date(),
+                    affects=Multilink("system"))
+
+3. We're going 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::
+
+    p = db.security.getPermission('View', 'support')
+    db.security.addPermissionToRole('SysAdmin', p)
+    p = db.security.getPermission('Edit', 'support')
+    db.security.addPermissionToRole('SysAdmin', p)
+
+   You would then (as an "admin" user) edit the details of the appropriate
+   users, and add "SysAdmin" to their Roles list.
+
+4. 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.
+
+5. 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".
+
+6. Create a new sidebar box for the new support class. Duplicate the
+   existing issues one, changing the "issue" class name to "support".
+
+6. Re-start your tracker and start using the new "support" class.
+
+
+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
+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')
+
+            # the passwords are stored in the "passwd.txt" file in the
+            # tracker home
+            file = os.path.join(self.db.config.TRACKER_HOME, 'passwd.txt')
+
+            # 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.
+
+We also remove the redundant password fields from the ``user.item``
+template.
+
+
 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
@@ -3343,7 +3074,7 @@
 
 
 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
@@ -3379,56 +3110,180 @@
         # now verify the password supplied against the LDAP store
 
 
-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>
+Changes to Tracker Behaviour
+----------------------------
+
+Stop "nosy" messages going to people on vacation
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+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
@@ -3560,7 +3415,7 @@
 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
@@ -3577,7 +3432,7 @@
 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
@@ -3597,7 +3452,7 @@
                     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
@@ -3620,7 +3475,7 @@
   
 
 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. 
@@ -3702,7 +3557,7 @@
 TODO: update this example to use the find() Class method.
 
 Caveats
-~~~~~~~
+:::::::
 
 A few problems with the design here can be noted:
 
@@ -3726,26 +3581,68 @@
     selected these topics a nosy topics. This will eliminate the
     loop over all users.
 
-
-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
+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
@@ -3814,8 +3711,28 @@
 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
@@ -3837,7 +3754,7 @@
 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``:
@@ -3883,6 +3800,154 @@
    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`_

Roundup Issue Tracker: http://roundup-tracker.org/