diff --git a/doc/customizing.txt b/doc/customizing.txt
index 3ecc7f5777d12c2430d455d159228f8d8dee191f..ddcd33ea6edf65df6d13d654fcdf924c41e65f1e 100644 (file)
--- a/doc/customizing.txt
+++ b/doc/customizing.txt
Customising Roundup
===================
-:Version: $Revision: 1.107 $
+:Version: $Revision: 1.133 $
.. This document borrows from the ZopeBook section on ZPT. The original is at:
http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx
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
"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
**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
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.::
# 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
#
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())
__ design.html
+Additional Detectors Ready For Use
+----------------------------------
+
Sample additional detectors that have been found useful will appear in
the ``'detectors'`` directory of the Roundup distribution. If you want
to use one, copy it to the ``'detectors'`` of your tracker instance:
created. The address is hard-coded into the detector, so edit it
before you use it (look for the text 'team@team.host') or you'll get
email errors!
+**creator_resolution.py**
+ Catch attempts to set the status to "resolved" - if the assignedto
+ user isn't the creator, then set the status to "confirm-done". Note that
+ "classic" Roundup doesn't have that status, so you'll have to add it. If
+ you don't want to though, it'll just use "in-progress" instead.
+**email_auditor.py**
+ If a file added to an issue is of type message/rfc822, we tack on the
+ extension .eml.
+ The reason for this is that Microsoft Internet Explorer will not open
+ things with a .eml attachment, as they deem it 'unsafe'. Worse yet,
+ they'll just give you an incomprehensible error message. For more
+ information, see the detector code - it has a length explanation.
+
+
+Auditor or Reactor?
+-------------------
- The detector code::
+Generally speaking, the following rules should be observed:
- from roundup import roundupdb
+**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.
- def newissuecopy(db, cl, nodeid, oldvalues):
- ''' Copy a message about new issues to a team address.
- '''
- # so use all the messages in the create
- change_note = cl.generateCreateNote(nodeid)
- # send a copy to the nosy list
- for msgid in cl.get(nodeid, 'messages'):
- try:
- # note: last arg must be a list
- cl.send_message(nodeid, msgid, change_note,
- ['team@team.host'])
- except roundupdb.MessageSendError, message:
- raise roundupdb.DetectorError, message
+Vetoing creation of or changes to items
+---------------------------------------
- def init(db):
- db.issue.react('create', newissuecopy)
+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
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
#
# 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)
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
.. 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``
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
- 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
----------------------------------
- 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**
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
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
~~~~~~~~~~~~~~~~~~~~
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
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.
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
~~~~~~~~~~~~~~~~~~
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
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
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
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
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
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
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
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
</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;
tells roundup which fields of the issue to display. Simply add
"category" to that list and it all should work.
+Adding a time log to your issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Adding in state transition control
-----------------------------------
+We want to log the dates and amount of time spent working on issues, and
+be able to give a summary of the total time spent on a particular issue.
-Sometimes tracker admins want to control the states that users may move
-issues to. You can do this by following these steps:
+1. Add a new class to your tracker ``dbinit.py``::
-1. make "status" a required variable. This is achieved by adding the
- following to the top of the form in the ``issue.item.html``
- template::
+ # storage for time logging
+ timelog = Class(db, "timelog", period=Interval())
- <input type="hidden" name="@required" value="status">
+ Note that we automatically get the date of the time log entry
+ creation through the standard property "creation".
- this will force users to select a status.
+2. Link to the new class from your issue class (again, in
+ ``dbinit.py``)::
-2. add a Multilink property to the status class::
+ issue = IssueClass(db, "issue",
+ assignedto=Link("user"), topic=Multilink("keyword"),
+ priority=Link("priority"), status=Link("status"),
+ times=Multilink("timelog"))
- stat = Class(db, "status", ... , transitions=Multilink('status'),
- ...)
+ the "times" property is the new link to the "timelog" class.
- and then edit the statuses already created, either:
+3. We'll need to let people add in times to the issue, so in the web
+ interface we'll have a new entry field. This is a special field
+ because unlike the other fields in the issue.item template, it
+ affects a different item (a timelog item) and not the template's
+ item, an issue. We have a special syntax for form fields that affect
+ items other than the template default item (see the cgi
+ documentation on `special form variables`_). In particular, we add a
+ field to capture a new timelog item's perdiod::
- a. through the web using the class list -> status class editor, or
- b. using the roundup-admin "set" command.
+ <tr>
+ <th>Time Log</th>
+ <td colspan=3><input type="text" name="timelog-1@period" />
+ <br />(enter as '3y 1m 4d 2:40:02' or parts thereof)
+ </td>
+ </tr>
+
+ and another hidden field that links that new timelog item (new
+ because it's marked as having id "-1") to the issue item. It looks
+ like this::
-3. add an auditor module ``checktransition.py`` in your tracker's
- ``detectors`` directory, for example::
+ <input type="hidden" name="@link@times" value="timelog-1" />
- def checktransition(db, cl, nodeid, newvalues):
- ''' Check that the desired transition is valid for the "status"
- property.
- '''
- if not newvalues.has_key('status'):
- return
- current = cl.get(nodeid, 'status')
- new = newvalues['status']
- if new == current:
- return
- ok = db.status.get(current, 'transitions')
- if new not in ok:
- raise ValueError, 'Status not allowed to move from "%s" to "%s"'%(
- db.status.get(current, 'name'), db.status.get(new, 'name'))
+ On submission, the "-1" timelog item will be created and assigned a
+ real item id. The "times" property of the issue will have the new id
+ added to it.
- def init(db):
- db.issue.audit('set', checktransition)
+4. We want to display a total of the time log times that have been
+ accumulated for an issue. To do this, we'll need to actually write
+ some Python code, since it's beyond the scope of PageTemplates to
+ perform such calculations. We do this by adding a method to the
+ TemplatingUtils class in our tracker ``interfaces.py`` module::
-4. in the ``issue.item.html`` template, change the status editing bit
- from::
+ class TemplatingUtils:
+ ''' Methods implemented on this class will be available to HTML
+ templates through the 'utils' variable.
+ '''
+ def totalTimeSpent(self, times):
+ ''' Call me with a list of timelog items (which have an
+ Interval "period" property)
+ '''
+ total = Interval('0d')
+ for time in times:
+ total += time.period._value
+ return total
- <th>Status</th>
- <td tal:content="structure context/status/menu">status</td>
+ Replace the ``pass`` line if one appears in your TemplatingUtils
+ class. As indicated in the docstrings, we will be able to access the
+ ``totalTimeSpent`` method via the ``utils`` variable in our templates.
- to::
+5. Display the time log for an issue::
- <th>Status</th>
- <td>
- <select tal:condition="context/id" name="status">
- <tal:block tal:define="ok context/status/transitions"
- tal:repeat="state db/status/list">
- <option tal:condition="python:state.id in ok"
- tal:attributes="
- value state/id;
- selected python:state.id == context.status.id"
- tal:content="state/name"></option>
- </tal:block>
- </select>
- <tal:block tal:condition="not:context/id"
- tal:replace="structure context/status/menu" />
- </td>
+ <table class="otherinfo" tal:condition="context/times">
+ <tr><th colspan="3" class="header">Time Log
+ <tal:block
+ tal:replace="python:utils.totalTimeSpent(context.times)" />
+ </th></tr>
+ <tr><th>Date</th><th>Period</th><th>Logged By</th></tr>
+ <tr tal:repeat="time context/times">
+ <td tal:content="time/creation"></td>
+ <td tal:content="time/period"></td>
+ <td tal:content="time/creator"></td>
+ </tr>
+ </table>
- which displays only the allowed status to transition to.
+ I put this just above the Messages log in my issue display. Note our
+ use of the ``totalTimeSpent`` method which will total up the times
+ for the issue and return a new Interval. That will be automatically
+ displayed in the template as text like "+ 1y 2:40" (1 year, 2 hours
+ and 40 minutes).
+8. If you're using a persistent web server - roundup-server or
+ mod_python for example - then you'll need to restart that to pick up
+ the code changes. When that's done, you'll be able to use the new
+ time logging interface.
-Displaying only message summaries in the issue display
-------------------------------------------------------
-Alter the issue.item template section for messages to::
+Tracking different types of issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- <table class="messages" tal:condition="context/messages">
- <tr><th colspan="5" class="header">Messages</th></tr>
- <tr tal:repeat="msg context/messages">
- <td><a tal:attributes="href string:msg${msg/id}"
- tal:content="string:msg${msg/id}"></a></td>
- <td tal:content="msg/author">author</td>
- <td class="date" tal:content="msg/date/pretty">date</td>
- <td tal:content="msg/summary">summary</td>
- <td>
- <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">
- remove</a>
- </td>
- </tr>
- </table>
+Sometimes you will want to track different types of issues - developer,
+customer support, systems, sales leads, etc. A single Roundup tracker is
+able to support multiple types of issues. This example demonstrates adding
+a customer support issue class to a tracker.
-Restricting the list of users that are assignable to a task
------------------------------------------------------------
+1. Figure out what information you're going to want to capture. OK, so
+ this is obvious, but sometimes it's better to actually sit down for a
+ while and think about the schema you're going to implement.
-1. In your tracker's "dbinit.py", create a new Role, say "Developer"::
+2. Add the new issue class to your tracker's ``dbinit.py`` - in this
+ example, we're adding a "system support" class. Just after the "issue"
+ class definition in the "open" function, add::
- db.security.addRole(name='Developer', description='A developer')
+ support = IssueClass(db, "support",
+ assignedto=Link("user"), topic=Multilink("keyword"),
+ status=Link("status"), deadline=Date(),
+ affects=Multilink("system"))
-2. Just after that, create a new Permission, say "Fixer", specific to
- "issue"::
+3. Copy the existing "issue.*" (item, search and index) templates in the
+ tracker's "html" to "support.*". Edit them so they use the properties
+ defined in the "support" class. Be sure to check for hidden form
+ variables like "required" to make sure they have the correct set of
+ required properties.
- p = db.security.addPermission(name='Fixer', klass='issue',
- description='User is allowed to be assigned to fix issues')
+4. Edit the modules in the "detectors", adding lines to their "init"
+ functions where appropriate. Look for "audit" and "react" registrations
+ on the "issue" class, and duplicate them for "support".
-3. Then assign the new Permission to your "Developer" Role::
+5. Create a new sidebar box for the new support class. Duplicate the
+ existing issues one, changing the "issue" class name to "support".
- db.security.addPermissionToRole('Developer', p)
+6. Re-start your tracker and start using the new "support" class.
-4. In the issue item edit page ("html/issue.item.html" in your tracker
- directory), use the new Permission in restricting the "assignedto"
- list::
- <select name="assignedto">
- <option value="-1">- no selection -</option>
- <tal:block tal:repeat="user db/user/list">
- <option tal:condition="python:user.hasPermission(
- 'Fixer', context._classname)"
- tal:attributes="
- value user/id;
- selected python:user.id == context.assignedto"
- tal:content="user/realname"></option>
- </tal:block>
- </select>
+Optionally, you might want to restrict the users able to access this new
+class to just the users with a new "SysAdmin" Role. To do this, we add
+some security declarations::
-For extra security, you may wish to setup an auditor to enforce the
-Permission requirement (install this as "assignedtoFixer.py" in your
-tracker "detectors" directory)::
+ p = db.security.getPermission('View', 'support')
+ db.security.addPermissionToRole('SysAdmin', p)
+ p = db.security.getPermission('Edit', 'support')
+ db.security.addPermissionToRole('SysAdmin', p)
- def assignedtoMustBeFixer(db, cl, nodeid, newvalues):
- ''' Ensure the assignedto value in newvalues is a used with the
- Fixer Permission
- '''
- if not newvalues.has_key('assignedto'):
- # don't care
- return
-
- # get the userid
- userid = newvalues['assignedto']
- if not db.security.hasPermission('Fixer', userid, cl.classname):
- raise ValueError, 'You do not have permission to edit %s'%cl.classname
+You would then (as an "admin" user) edit the details of the appropriate
+users, and add "SysAdmin" to their Roles list.
- def init(db):
- db.issue.audit('set', assignedtoMustBeFixer)
- db.issue.audit('create', assignedtoMustBeFixer)
-
-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="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>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>
+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.
- ... 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
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.
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>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
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
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
# now verify the password supplied against the LDAP store
-Enabling display of either message summaries or the entire messages
--------------------------------------------------------------------
+Changes to Tracker Behaviour
+----------------------------
-This is pretty simple - all we need to do is copy the code from the
-example `displaying only message summaries in the issue display`_ into
-our template alongside the summary display, and then introduce a switch
-that shows either one or the other. We'll use a new form variable,
-``@whole_messages`` to achieve this::
+Stop "nosy" messages going to people on vacation
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- <table class="messages" tal:condition="context/messages">
- <tal:block tal:condition="not:request/form/@whole_messages/value | python:0">
- <tr><th colspan="3" class="header">Messages</th>
- <th colspan="2" class="header">
- <a href="?@whole_messages=yes">show entire messages</a>
- </th>
- </tr>
- <tr tal:repeat="msg context/messages">
- <td><a tal:attributes="href string:msg${msg/id}"
- tal:content="string:msg${msg/id}"></a></td>
- <td tal:content="msg/author">author</td>
- <td class="date" tal:content="msg/date/pretty">date</td>
- <td tal:content="msg/summary">summary</td>
+When users go on vacation and set up vacation email bouncing, you'll
+start to see a lot of messages come back through Roundup "Fred is on
+vacation". Not very useful, and relatively easy to stop.
+
+1. add a "vacation" flag to your users::
+
+ user = Class(db, "user",
+ username=String(), password=Password(),
+ address=String(), realname=String(),
+ phone=String(), organisation=String(),
+ alternate_addresses=String(),
+ roles=String(), queries=Multilink("query"),
+ vacation=Boolean())
+
+2. So that users may edit the vacation flags, add something like the
+ following to your ``user.item`` template::
+
+ <tr>
+ <th>On Vacation</th>
+ <td tal:content="structure context/vacation/field">vacation</td>
+ </tr>
+
+3. edit your detector ``nosyreactor.py`` so that the ``nosyreaction()``
+ consists of::
+
+ def nosyreaction(db, cl, nodeid, oldvalues):
+ users = db.user
+ messages = db.msg
+ # send a copy of all new messages to the nosy list
+ for msgid in determineNewMessages(cl, nodeid, oldvalues):
+ try:
+ # figure the recipient ids
+ sendto = []
+ seen_message = {}
+ recipients = messages.get(msgid, 'recipients')
+ for recipid in messages.get(msgid, 'recipients'):
+ seen_message[recipid] = 1
+
+ # figure the author's id, and indicate they've received
+ # the message
+ authid = messages.get(msgid, 'author')
+
+ # possibly send the message to the author, as long as
+ # they aren't anonymous
+ if (db.config.MESSAGES_TO_AUTHOR == 'yes' and
+ users.get(authid, 'username') != 'anonymous'):
+ sendto.append(authid)
+ seen_message[authid] = 1
+
+ # now figure the nosy people who weren't recipients
+ nosy = cl.get(nodeid, 'nosy')
+ for nosyid in nosy:
+ # Don't send nosy mail to the anonymous user (that
+ # user shouldn't appear in the nosy list, but just
+ # in case they do...)
+ if users.get(nosyid, 'username') == 'anonymous':
+ continue
+ # make sure they haven't seen the message already
+ if not seen_message.has_key(nosyid):
+ # send it to them
+ sendto.append(nosyid)
+ recipients.append(nosyid)
+
+ # generate a change note
+ if oldvalues:
+ note = cl.generateChangeNote(nodeid, oldvalues)
+ else:
+ note = cl.generateCreateNote(nodeid)
+
+ # we have new recipients
+ if sendto:
+ # filter out the people on vacation
+ sendto = [i for i in sendto
+ if not users.get(i, 'vacation', 0)]
+
+ # map userids to addresses
+ sendto = [users.get(i, 'address') for i in sendto]
+
+ # update the message's recipients list
+ messages.set(msgid, recipients=recipients)
+
+ # send the message
+ cl.send_message(nodeid, msgid, note, sendto)
+ except roundupdb.MessageSendError, message:
+ raise roundupdb.DetectorError, message
+
+ Note that this is the standard nosy reaction code, with the small
+ addition of::
+
+ # filter out the people on vacation
+ sendto = [i for i in sendto if not users.get(i, 'vacation', 0)]
+
+ which filters out the users that have the vacation flag set to true.
+
+Adding in state transition control
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Sometimes tracker admins want to control the states that users may move
+issues to. You can do this by following these steps:
+
+1. make "status" a required variable. This is achieved by adding the
+ following to the top of the form in the ``issue.item.html``
+ template::
+
+ <input type="hidden" name="@required" value="status">
+
+ this will force users to select a status.
+
+2. add a Multilink property to the status class::
+
+ stat = Class(db, "status", ... , transitions=Multilink('status'),
+ ...)
+
+ and then edit the statuses already created, either:
+
+ a. through the web using the class list -> status class editor, or
+ b. using the roundup-admin "set" command.
+
+3. add an auditor module ``checktransition.py`` in your tracker's
+ ``detectors`` directory, for example::
+
+ def checktransition(db, cl, nodeid, newvalues):
+ ''' Check that the desired transition is valid for the "status"
+ property.
+ '''
+ if not newvalues.has_key('status'):
+ return
+ current = cl.get(nodeid, 'status')
+ new = newvalues['status']
+ if new == current:
+ return
+ ok = db.status.get(current, 'transitions')
+ if new not in ok:
+ raise ValueError, 'Status not allowed to move from "%s" to "%s"'%(
+ db.status.get(current, 'name'), db.status.get(new, 'name'))
+
+ def init(db):
+ db.issue.audit('set', checktransition)
+
+4. in the ``issue.item.html`` template, change the status editing bit
+ from::
+
+ <th>Status</th>
+ <td tal:content="structure context/status/menu">status</td>
+
+ to::
+
+ <th>Status</th>
<td>
- <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>
+ <select tal:condition="context/id" name="status">
+ <tal:block tal:define="ok context/status/transitions"
+ tal:repeat="state db/status/list">
+ <option tal:condition="python:state.id in ok"
+ tal:attributes="
+ value state/id;
+ selected python:state.id == context.status.id"
+ tal:content="state/name"></option>
+ </tal:block>
+ </select>
+ <tal:block tal:condition="not:context/id"
+ tal:replace="structure context/status/menu" />
</td>
- </tr>
- </tal:block>
- <tal:block tal:condition="request/form/@whole_messages/value | python:0">
- <tr><th colspan="2" class="header">Messages</th>
- <th class="header">
- <a href="?@whole_messages=">show only summaries</a>
- </th>
- </tr>
- <tal:block tal:repeat="msg context/messages">
- <tr>
- <th tal:content="msg/author">author</th>
- <th class="date" tal:content="msg/date/pretty">date</th>
- <th style="text-align: right">
- (<a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>)
- </th>
- </tr>
- <tr><td colspan="3" tal:content="msg/content"></td></tr>
- </tal:block>
- </tal:block>
- </table>
+ which displays only the allowed status to transition to.
Blocking issues that depend on other issues
--------------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
We needed the ability to mark certain issues as "blockers" - that is,
they can't be resolved until another issue (the blocker) they rely on is
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
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
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
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.
TODO: update this example to use the find() Class method.
Caveats
-~~~~~~~
+:::::::
A few problems with the design here can be noted:
selected these topics a nosy topics. This will eliminate the
loop over all users.
+Changes to Security and Permissions
+-----------------------------------
-Adding action links to the index page
--------------------------------------
+Restricting the list of users that are assignable to a task
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Add a column to the item.index.html template.
+1. In your tracker's "dbinit.py", create a new Role, say "Developer"::
-Resolving the issue::
+ db.security.addRole(name='Developer', description='A developer')
- <a tal:attributes="href
- string:issue${i/id}?:status=resolved&:action=edit">resolve</a>
+2. Just after that, create a new Permission, say "Fixer", specific to
+ "issue"::
-"Take" the issue::
+ p = db.security.addPermission(name='Fixer', klass='issue',
+ description='User is allowed to be assigned to fix issues')
- <a tal:attributes="href
- string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a>
+3. Then assign the new Permission to your "Developer" Role::
-... and so on
+ db.security.addPermissionToRole('Developer', p)
+
+4. In the issue item edit page ("html/issue.item.html" in your tracker
+ directory), use the new Permission in restricting the "assignedto"
+ list::
+
+ <select name="assignedto">
+ <option value="-1">- no selection -</option>
+ <tal:block tal:repeat="user db/user/list">
+ <option tal:condition="python:user.hasPermission(
+ 'Fixer', context._classname)"
+ tal:attributes="
+ value user/id;
+ selected python:user.id == context.assignedto"
+ tal:content="user/realname"></option>
+ </tal:block>
+ </select>
+
+For extra security, you may wish to setup an auditor to enforce the
+Permission requirement (install this as "assignedtoFixer.py" in your
+tracker "detectors" directory)::
+
+ def assignedtoMustBeFixer(db, cl, nodeid, newvalues):
+ ''' Ensure the assignedto value in newvalues is a used with the
+ Fixer Permission
+ '''
+ if not newvalues.has_key('assignedto'):
+ # don't care
+ return
+
+ # get the userid
+ userid = newvalues['assignedto']
+ if not db.security.hasPermission('Fixer', userid, cl.classname):
+ raise ValueError, 'You do not have permission to edit %s'%cl.classname
+
+ def init(db):
+ db.issue.audit('set', assignedtoMustBeFixer)
+ db.issue.audit('create', assignedtoMustBeFixer)
+
+So now, if an edit action attempts to set "assignedto" to a user that
+doesn't have the "Fixer" Permission, the error will be raised.
Users may only edit their issues
---------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Users registering themselves are granted Provisional access - meaning they
have access to edit the issues they submit, but not others. We create a new
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"> </td>
+
+ to::
+
+ <td tal:condition="request/show/status"
+ tal:content="structure i/status/field"> </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`_