Code

fixes to time tracking customisation
[roundup.git] / doc / customizing.txt
index 4caa99ac5f814c80df46c799faea080a5f78bfb9..eb6b8adceeed1c70a49388a0f576c9aea12d2d35 100644 (file)
@@ -2,7 +2,7 @@
 Customising Roundup
 ===================
 
-:Version: $Revision: 1.52 $
+:Version: $Revision: 1.72 $
 
 .. This document borrows from the ZopeBook section on ZPT. The original is at:
    http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx
@@ -107,13 +107,25 @@ The configuration variables available are:
  The email address that e-mail sent to roundup should go to. Think of it as the
  tracker's personal e-mail address.
 
-**TRACKER_WEB** - ``'http://your.tracker.url.example/'``
+**TRACKER_WEB** - ``'http://tracker.example/cgi-bin/roundup.cgi/bugs/'``
  The web address that the tracker is viewable at. This will be included in
- information sent to users of the tracker.
+ information sent to users of the tracker. The URL **must** include the
+ cgi-bin part or anything else that is required to get to the home page of
+ the tracker. You **must** include a trailing '/' in the URL.
 
 **ADMIN_EMAIL** - ``'roundup-admin@%s'%MAIL_DOMAIN``
  The email address that roundup will complain to if it runs into trouble.
 
+**EMAIL_FROM_TAG** - ``''``
+ Additional text to include in the "name" part of the ``From:`` address used
+ in nosy messages. If the sending user is "Foo Bar", the ``From:`` line is
+ usually::
+     "Foo Bar" <issue_tracker@tracker.example>
+
+ the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so::
+
+    "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
+
 **MESSAGES_TO_AUTHOR** - ``'yes'`` or``'no'``
  Send nosy messages to the author of the message.
 
@@ -170,12 +182,22 @@ tracker is attempted.::
     # The email address that mail to roundup should go to
     TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN
 
-    # The web address that the tracker is viewable at
-    TRACKER_WEB = 'http://your.tracker.url.example/'
+    # The web address that the tracker is viewable at. This will be included in
+    # information sent to users of the tracker. The URL MUST include the cgi-bin
+    # part or anything else that is required to get to the home page of the
+    # tracker. You MUST include a trailing '/' in the URL.
+    TRACKER_WEB = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/'
 
     # The email address that roundup will complain to if it runs into trouble
     ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
 
+    # Additional text to include in the "name" part of the From: address used
+    # in nosy messages. If the sending user is "Foo Bar", the From: line is
+    # usually: "Foo Bar" <issue_tracker@tracker.example>
+    # the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so:
+    #    "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
+    EMAIL_FROM_TAG = ""
+
     # Send nosy messages to the author of the message
     MESSAGES_TO_AUTHOR = 'no'           # either 'yes' or 'no'
 
@@ -206,6 +228,14 @@ tracker is attempted.::
     MAIL_DEFAULT_CLASS = 'issue'   # use "issue" class by default
     #MAIL_DEFAULT_CLASS = ''        # disable (or just comment the var out)
 
+    # 
+    # SECURITY DEFINITIONS
+    #
+    # define the Roles that a user gets when they register with the tracker
+    # these are a comma-separated string of role names (e.g. 'Admin,User')
+    NEW_WEB_USER_ROLES = 'User'
+    NEW_EMAIL_USER_ROLES = 'User'
+
 Tracker Schema
 ==============
 
@@ -437,9 +467,12 @@ case though, so be careful to use the right one.
     the create() methods.
 
 **Changing content after tracker initialisation**
-    Use the roundup-admin interface's create, set and retire methods to add,
-    alter or remove items from the classes in question.
+    As the "admin" user, click on the "class list" link in the web interface
+    to bring up a list of all database classes. Click on the name of the class
+    you wish to change the content of.
 
+    You may also use the roundup-admin interface's create, set and retire
+    methods to add, alter or remove items from the classes in question.
 
 See "`adding a new field to the classic schema`_" for an example that requires
 database content changes.
@@ -580,6 +613,20 @@ Example Scenarios
    <option tal:condition="python:request.user.hasPermission('Closer')"
            value="resolved">Resolved</option>
  
+**don't give users who register through email web access**
+ Create a new Role called "Email User" which has all the Permissions of the
+ normal "User" Role minus the "Web Access" Permission. This will allow users
+ to send in emails to the tracker, but not access the web interface.
+
+**let some users edit the details of all users**
+ Create a new Role called "User Admin" which has the Permission for editing
+ users::
+
+    db.security.addRole(name='User Admin', description='Managing users')
+    p = db.security.getPermission('Edit', 'user')
+    db.security.addPermissionToRole('User Admin', p)
+
+ and assign the Role to the users who need the permission.
 
 
 Web Interface
@@ -690,18 +737,21 @@ triggered by using a ``:action`` CGI variable, where the value is one of:
 
 **login**
  Attempt to log a user in.
+
 **logout**
  Log the user out - make them "anonymous".
+
 **register**
  Attempt to create a new user based on the contents of the form and then log
  them in.
+
 **edit**
  Perform an edit of an item in the database. There are some special form
  elements you may use:
 
  :link=designator:property and :multilink=designator:property
   The value specifies an item designator and the property on that
-  item to add _this_ item to as a link or multilink.
+  item to add *this* item to as a link or multilink.
  :note
   Create a message and attach it to the current item's
   "messages" property.
@@ -711,11 +761,20 @@ triggered by using a ``:action`` CGI variable, where the value is one of:
   the :note if it's supplied.
  :required=property,property,...
   The named properties are required to be filled in the form.
+ :remove:<propname>=id(s)
+  The ids will be removed from the multilink property. You may have multiple
+  :remove:<propname> form elements for a single <propname>.
+ :add:<propname>=id(s)
+  The ids will be added to the multilink property. You may have multiple
+  :add:<propname> form elements for a single <propname>.
 
 **new**
  Add a new item to the database. You may use the same special form elements
  as in the "edit" action.
 
+**retire**
+ Retire the item in the database.
+
 **editCSV**
  Performs an edit of all of a class' items in one go. See also the
  *class*.csv templating method which generates the CSV data to be edited, and
@@ -940,7 +999,7 @@ forms:
    in place if the "foo" form variable doesn't exist.
 
 **String Expressions** - eg. ``string:hello ${user/name}``
-   These expressions are simple string interpolations (though they can be just
+   These expressions are simple string interpolations though they can be just
    plain strings with no interpolation if you want. The expression in the
    ``${ ... }`` is just a path expression as above.
 
@@ -1020,7 +1079,6 @@ The following variables are available to templates.
   `hyperdb class wrapper`_ or a `hyperdb item wrapper`_
 **request**
   Includes information about the current request, including:
-   - the url
    - the current index information (``filterspec``, ``filter`` args,
      ``properties``, etc) parsed out of the form. 
    - methods for easy filterspec link generation
@@ -1028,10 +1086,11 @@ The following variables are available to templates.
    - *form*
      The current CGI form information as a mapping of form argument
      name to value
-**tracker**
-  The current tracker
+**config**
+  This variable holds all the values defined in the tracker config.py file 
+  (eg. TRACKER_NAME, etc.)
 **db**
-  The current database, through which db.config may be reached.
+  The current database, used to access arbitrary database items.
 **templates**
   Access to all the tracker templates by name. Used mainly in *use-macro*
   commands.
@@ -1182,35 +1241,64 @@ The property wrapper has some useful attributes:
 Attribute       Description
 =============== =============================================================
 _name           the name of the property
-_value          the value of the property if any
+_value          the value of the property if any - this is the actual value
+                retrieved from the hyperdb for this property
 =============== =============================================================
 
 There are several methods available on these wrapper objects:
 
-=========== =================================================================
-Method      Description
-=========== =================================================================
-plain       render a "plain" representation of the property
-field       render a form edit field for the property
-stext       only on String properties - render the value of the
-            property as StructuredText (requires the StructureText module
-            to be installed separately)
-multiline   only on String properties - render a multiline form edit
-            field for the property
-email       only on String properties - render the value of the 
-            property as an obscured email address
-confirm     only on Password properties - render a second form edit field for
-            the property, used for confirmation that the user typed the
-            password correctly. Generates a field with name "name:confirm".
-reldate     only on Date properties - render the interval between the
-            date and now
-pretty      only on 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
-=========== =================================================================
+========= =====================================================================
+Method    Description
+========= =====================================================================
+plain     render a "plain" representation of the property. This method may
+          take two arguments:
+
+          escape
+           If true, escape the text so it is HTML safe (default: no). The
+           reason this defaults to off is that text is usually escaped
+           at a later stage by the TAL commands, unless the "structure"
+           option is used in the template. The following are all equivalent::
+
+            <p tal:content="structure python:msg.content.plain(escape=1)" />
+            <p tal:content="python:msg.content.plain()" />
+            <p tal:content="msg/content/plain" />
+            <p tal:content="msg/content" />
+
+           Usually you'll only want to use the escape option in a complex
+           expression.
+
+          hyperlink
+           If true, turn URLs, email addresses and hyperdb item designators
+           in the text into hyperlinks (default: no). Note that you'll need
+           to use the "structure" TAL option if you want to use this::
+
+            <p tal:content="structure python:msg.content.plain(hyperlink=1)" />
+
+           Note also that the text is automatically HTML-escape before the
+           hyperlinking transformation.
+
+field     render an appropriate form edit field for the property - for most
+          types this is a text entry box, but for Booleans it's a tri-state
+          yes/no/neither selection.
+stext     only on String properties - render the value of the
+          property as StructuredText (requires the StructureText module
+          to be installed separately)
+multiline only on String properties - render a multiline form edit
+          field for the property
+email     only on String properties - render the value of the 
+          property as an obscured email address
+confirm   only on Password properties - render a second form edit field for
+          the property, used for confirmation that the user typed the
+          password correctly. Generates a field with name "name:confirm".
+reldate   only on Date properties - render the interval between the
+          date and now
+pretty    only on 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
+========= =====================================================================
 
 The request variable
 ~~~~~~~~~~~~~~~~~~~~
@@ -1226,7 +1314,6 @@ Variable    Holds
 =========== =================================================================
 form        the CGI form as a cgi.FieldStorage
 env         the CGI environment variables
-url         the current URL path for this request
 base        the base URL for this tracker
 user        a HTMLUser instance for this user
 classname   the current classname (possibly None)
@@ -1329,7 +1416,8 @@ or the python expression::
 The utils variable
 ~~~~~~~~~~~~~~~~~~
 
-Note: this is implemented by the roundup.cgi.templating.TemplatingUtils class.
+Note: this is implemented by the roundup.cgi.templating.TemplatingUtils class,
+but it may be extended as described below.
 
 =============== =============================================================
 Method          Description
@@ -1337,6 +1425,12 @@ Method          Description
 Batch           return a batch object using the supplied list
 =============== =============================================================
 
+You may add additional utility methods by writing them in your tracker
+``interfaces.py`` module's ``TemplatingUtils`` class. See `adding a time log
+to your issues`_ for an example. The TemplatingUtils class itself will have a
+single attribute, ``client``, which may be used to access the ``client.db``
+when you need to perform arbitrary database queries.
+
 Batching
 ::::::::
 
@@ -1456,6 +1550,10 @@ the "status" and "topic" properties, and the table includes columns for the
 Searching Views
 ---------------
 
+Note: if you add a new column to the ``:columns`` form variable potentials
+      then you will need to add the column to the appropriate `index views`_
+      template so it is actually displayed.
+
 This is one of the class context views. The template used is typically
 "*classname*.search". The form on this page should have "search" as its
 ``:action`` variable. The "search" action:
@@ -1485,7 +1583,6 @@ action are:
   template schema does not.
 
 
-
 Item Views
 ----------
 
@@ -2075,8 +2172,10 @@ to.
 
      stat = Class(db, "status", ... , transitions=Multilink('status'), ...)
 
-   and then edit the statuses already created through the web using the
-   generic class list / CSV editor.
+   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.
 
 2. add an auditor module ``checktransition.py`` in your tracker's
    ``detectors`` directory::
@@ -2124,26 +2223,23 @@ to.
    which displays only the allowed status to transition to.
 
 
-Displaying entire message contents in the issue display
--------------------------------------------------------
+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=3 class="header">Messages</th></tr>
-  <tal:block tal:repeat="msg context/messages/reverse">
-   <tr>
-    <th><a tal:attributes="href string:msg${msg/id}"
-           tal:content="string:msg${msg/id}"></a></th>
-    <th tal:content="string:Author: ${msg/author}">author</th>
-    <th tal:content="string:Date: ${msg/date}">date</th>
-   </tr>
-   <tr>
-    <td colspan="3" class="content">
-     <pre tal:content="msg/content">content</pre>
-    </td>
-   </tr>
-  </tal:block>
+  <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>
 
 Restricting the list of users that are assignable to a task
@@ -2257,7 +2353,7 @@ Setting up a "wizard" (or "druid") for controlled adding of issues
    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.Class.actions + (
+    actions = client.Client.actions + (
         ('page1_submit', 'page1SubmitAction'),
     )
 
@@ -2404,6 +2500,363 @@ very useful, and relatively easy to stop.
    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, just below the change note box::
+
+    <tr>
+     <th nowrap>Time Log</th>
+     <td colspan=3><input name=":timelog">
+      (enter as "3y 1m 4d 2:40:02" or parts thereof)
+     </td>
+    </tr>
+
+   Note that we've made up a new form variable, but since we place a colon ":"
+   in front of it, it won't clash with any existing property variables. The
+   names you *can't* use are ``:note``, ``:file``, ``:action``, ``:required``
+   and ``:template``. These variables are described in the section
+   `performing actions in web requests`_.
+
+4. We also need to handle this new field in the CGI interface - the way to
+   do this is through implementing a new form action (see `Setting up a
+   "wizard" (or "druid") for controlled adding of issues`_ for another example
+   where we implemented a new CGI form action).
+
+   In this case, we'll want our action to:
+
+   1. create a new "timelog" entry,
+   2. fake that the issue's "times" property has been edited, and then
+   3. call the normal CGI edit action handler.
+
+   The code to do this is::
+
+    class Client(client.Client): 
+        ''' derives basic CGI implementation from the standard module, 
+            with any specific extensions 
+        ''' 
+        actions = client.Client.actions + (
+            ('edit_with_timelog', 'timelogEditAction'),
+        )
+
+        def timelogEditAction(self):
+            ''' Handle the creation of a new time log entry if necessary.
+
+                If we create a new entry, fake up a CGI form value for the
+                altered "times" property of the issue being edited.
+   
+                Punt to the regular edit action when we're done.
+            '''
+            # if there's a timelog value specified, create an entry
+            if self.form.has_key(':timelog') and \
+                    self.form[':timelog'].value.strip():
+                period = Interval(self.form[':timelog'].value)
+                # create it
+                newid = self.db.timelog.create(period=period)
+
+                # if we're editing an existing item, get the old timelog value
+                if self.nodeid:
+                    l = self.db.issue.get(self.nodeid, 'times')
+                    l.append(newid)
+                else:
+                    l = [newid]
+
+                # now make the fake CGI form values
+                for entry in l:
+                    self.form.list.append(MiniFieldStorage('times', entry))
+
+            # punt to the normal edit action
+            return self.editItemAction()
+   
+   you add this code to your Client class in your tracker's ``interfaces.py``
+   file. Locate the section that looks like::
+
+    class Client:
+        ''' derives basic CGI implementation from the standard module, 
+            with any specific extensions 
+        ''' 
+        pass
+
+   and insert this code in place of the ``pass`` statement.
+
+5. You'll also need to modify your ``issue.item`` form submit action so it
+   calls the time logging action we just created. The current template will
+   look like this::
+
+    <tr>
+     <td>&nbsp;</td>
+     <td colspan=3 tal:content="structure context/submit">
+      submit button will go here
+     </td>
+    </tr>
+
+   replace it with this::
+
+    <tr>
+     <td>&nbsp;</td>
+     <td colspan=3>
+      <tal:block tal:condition="context/id">
+        <input type="hidden" name=":action" value="edit_with_timelog">
+        <input type="submit" name="submit" value="Submit Changes">
+      </tal:block>
+      <tal:block tal:condition="not:context/id">
+        <input type="hidden" name=":action" value="new">
+        <input type="submit" name="submit" value="Submit New Issue">
+      </tal:block>
+     </td>
+    </tr>
+
+   The important change is setting the action to "edit_with_timelog" for
+   edit operations (where the item exists)
+
+6. 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.
+
+7. 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 and primary
+user group.
+
+Roundup can use this store as its primary source of user information, but it
+needs additional information too - email address(es), roundup Roles, vacation
+flags, roundup hyperdb item ids, etc. Also, "retired" users must still exist
+in the user database, unlike some passwd files in which the users are removed
+when they no longer have access to a system.
+
+To make use of the passwd file, we therefore synchronise between the two user
+stores. We also use the passwd file to validate the user logins, as described
+in the previous example, `using an external password validation source`_. We
+keep the users lists in sync using a fairly simple script that runs once a
+day, or several times an hour if more immediate access is needed. In short, it:
+
+1. parses the passwd file, finding usernames, passwords and real names,
+2. compares that list to the current roundup user list:
+
+   a. entries no longer in the passwd file are *retired*
+   b. entries with mismatching real names are *updated*
+   c. entries only exist in the passwd file are *created*
+
+3. send an email to administrators to let them know what's been done.
+
+The retiring and updating are simple operations, requiring only a call to
+``retire()`` or ``set()``. The creation operation requires more information
+though - the user's email address and their roundup Roles. We're going to
+assume that the user's email address is the same as their login name, so we
+just append the domain name to that. The Roles are determined using the
+passwd group identifier - mapping their UN*X group to an appropriate set of
+Roles.
+
+The script to perform all this, broken up into its main components, is as
+follows. Firstly, we import the necessary modules and open the tracker we're
+to work on::
+
+    import sys, os, smtplib
+    from roundup import instance, date
+
+    # open the tracker
+    tracker_home = sys.argv[1]
+    tracker = instance.open(tracker_home)
+
+Next we read in the *passwd* file from the tracker home::
+
+    # read in the users
+    file = os.path.join(tracker_home, 'users.passwd')
+    users = [x.strip().split(':') for x in open(file).readlines()]
+
+Handle special users (those to ignore in the file, and those who don't appear
+in the file)::
+
+    # users to not keep ever, pre-load with the users I know aren't
+    # "real" users
+    ignore = ['ekmmon', 'bfast', 'csrmail']
+
+    # users to keep - pre-load with the roundup-specific users
+    keep = ['comment_pool', 'network_pool', 'admin', 'dev-team', 'cs_pool',
+        'anonymous', 'system_pool', 'automated']
+
+Now we map the UN*X group numbers to the Roles that users should have::
+
+    roles = {
+     '501': 'User,Tech',  # tech
+     '502': 'User',       # finance
+     '503': 'User,CSR',   # customer service reps
+     '504': 'User',       # sales
+     '505': 'User',       # marketing
+    }
+
+Now we do all the work. Note that the body of the script (where we have the
+tracker database open) is wrapped in a ``try`` / ``finally`` clause, so that
+we always close the database cleanly when we're finished. So, we now do all
+the work::
+
+    # open the database
+    db = tracker.open('admin')
+    try:
+        # store away messages to send to the tracker admins
+        msg = []
+
+        # loop over the users list read in from the passwd file
+        for user,passw,uid,gid,real,home,shell in users:
+            if user in ignore:
+                # this user shouldn't appear in our tracker
+                continue
+            keep.append(user)
+            try:
+                # see if the user exists in the tracker
+                uid = db.user.lookup(user)
+
+                # yes, they do - now check the real name for correctness
+                if real != db.user.get(uid, 'realname'):
+                    db.user.set(uid, realname=real)
+                    msg.append('FIX %s - %s'%(user, real))
+            except KeyError:
+                # nope, the user doesn't exist
+                db.user.create(username=user, realname=real,
+                    address='%s@ekit-inc.com'%user, roles=roles[gid])
+                msg.append('ADD %s - %s (%s)'%(user, real, roles[gid]))
+
+        # now check that all the users in the tracker are also in our "keep"
+        # list - retire those who aren't
+        for uid in db.user.list():
+            user = db.user.get(uid, 'username')
+            if user not in keep:
+                db.user.retire(uid)
+                msg.append('RET %s'%user)
+
+        # if we did work, then send email to the tracker admins
+        if msg:
+            # create the email
+            msg = '''Subject: %s user database maintenance
+
+            %s
+            '''%(db.config.TRACKER_NAME, '\n'.join(msg))
+
+            # send the email
+            smtp = smtplib.SMTP(db.config.MAILHOST)
+            addr = db.config.ADMIN_EMAIL
+            smtp.sendmail(addr, addr, msg)
+
+        # now we're done - commit the changes
+        db.commit()
+    finally:
+        # always close the database cleanly
+        db.close()
+
+And that's it!
+
+
+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 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>
+  </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>
+
+
 -------------------
 
 Back to `Table of Contents`_