diff --git a/doc/customizing.txt b/doc/customizing.txt
index 120eaff055afd73b2b4aaa85597bfd8dd01c04f2..7205a2fcfd5a275952848d42d87c34743ded61a3 100644 (file)
--- a/doc/customizing.txt
+++ b/doc/customizing.txt
Customising Roundup
===================
-:Version: $Revision: 1.97 $
+:Version: $Revision: 1.117 $
.. This document borrows from the ZopeBook section on ZPT. The original is at:
http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx
Before you get too far, it's probably worth having a quick read of the Roundup
`design documentation`_.
-Customisation of Roundup can take one of five forms:
+Customisation of Roundup can take one of six forms:
1. `tracker configuration`_ file changes
2. database, or `tracker schema`_ changes
3. "definition" class `database content`_ changes
4. behavioural changes, through detectors_
-5. `access controls`_
+5. `security / access controls`_
6. change the `web interface`_
The third case is special because it takes two distinctly different forms
**EMAIL_KEEP_QUOTED_TEXT** - ``'yes'`` or ``'no'``
Keep email citations. Citations are the part of e-mail which the sender has
- quoted in their reply to previous e-mail.
+ quoted in their reply to previous e-mail with ``>`` or ``|`` characters at
+ the start of the line.
**EMAIL_LEAVE_BODY_UNCHANGED** - ``'no'``
Preserve the email body as is. Enabiling this will cause the entire message
- body to be stored, including all citations and signatures. It should be
- either ``'yes'`` or ``'no'``.
+ body to be stored, including all citations, signatures and Outlook-quoted
+ sections (ie. "Original Message" blocks). It should be either ``'yes'``
+ or ``'no'``.
**MAIL_DEFAULT_CLASS** - ``'issue'`` or ``''``
Default class to use in the mailgw if one isn't supplied in email
subjects. To disable, comment out the variable below or leave it blank.
+**HTML_VERSION** - ``'html4'`` or ``'xhtml'``
+ HTML version to generate. The templates are html4 by default. If you
+ wish to make them xhtml, then you'll need to change this var to 'xhtml'
+ too so all auto-generated HTML is compliant.
+
+**EMAIL_CHARSET** - ``utf-8`` (or ``iso-8859-1`` for Eudora users)
+ Character set to encode email headers with. We use utf-8 by default, as
+ it's the most flexible. Some mail readers (eg. Eudora) can't cope with
+ that, so you might need to specify a more limited character set (eg.
+ 'iso-8859-1'.
+
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.::
MAIL_DEFAULT_CLASS = 'issue' # use "issue" class by default
#MAIL_DEFAULT_CLASS = '' # disable (or just comment the var out)
+ # HTML version to generate. The templates are html4 by default. If you
+ # wish to make them xhtml, then you'll need to change this var to 'xhtml'
+ # too so all auto-generated HTML is compliant.
+ HTML_VERSION = 'html4' # either 'html4' or 'xhtml'
+
+ # Character set to encode email headers with. We use utf-8 by default, as
+ # it's the most flexible. Some mail readers (eg. Eudora) can't cope with
+ # that, so you might need to specify a more limited character set (eg.
+ # 'iso-8859-1'.
+ EMAIL_CHARSET = 'utf-8'
+ #EMAIL_CHARSET = 'iso-8859-1' # use this instead for Eudora users
+
#
# SECURITY DEFINITIONS
#
priority=Link("priority"))
issue.setkey('title')
+
+What you can't do to the schema
+-------------------------------
+
+You must never:
+
+**Remove the users class**
+ This class is the only *required* class in Roundup. Similarly, its
+ username, password and address properties must never be removed.
+
+**Change the type of a property**
+ Property types must *never* be changed - the database simply doesn't take
+ this kind of action into account. Note that you can't just remove a
+ property and re-add it as a new type either. If you wanted to make the
+ assignedto property a Multilink, you'd need to create a new property
+ assignedto_list and remove the old assignedto property.
+
+
+What you can do to the schema
+-----------------------------
+
+Your schema may be changed at any time before or after the tracker has been
+initialised (or used). You may:
+
+**Add new properties to classes, or add whole new classes**
+ This is painless and easy to do - there are generally no repurcussions
+ from adding new information to a tracker's schema.
+
+**Remove properties**
+ Removing properties is a little more tricky - you need to make sure that
+ the property is no longer used in the `web interface`_ *or* by the
+ detectors_.
+
+
+
Classes and Properties - creating a new information store
---------------------------------------------------------
requires database content changes.
-Access Controls
-===============
+Security / Access Controls
+==========================
A set of Permissions is built into the security module by default:
- 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**
This is equivalent to::
@link@messages=msg-1
- @msg-1@content=value
+ msg-1@content=value
except that in addition, the "author" and "date" properties of
"msg-1" are set to the userid of the submitter, and the current
This is equivalent to::
@link@files=file-1
- @file-1@content=value
+ file-1@content=value
The String content value is handled as described above for file
uploads.
Default templates
-----------------
+The default templates are html4 compliant. If you wish to change them to be
+xhtml compliant, you'll need to change the ``HTML_VERSION`` configuration
+variable in ``config.py`` to ``'xhtml'`` instead of ``'html4'``.
+
Most customisation of the web view can be done by modifying the
templates in the tracker ``'html'`` directory. There are several types
of files in there. The *minimal* template includes:
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
+ with some timezone offset, for example::
+
+ python:context.creation.local(10)
+
+ will render the date with a +10 hour offset.
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
<table class="form">
<tr>
- <th nowrap>Title</th>
+ <th>Title</th>
<td colspan="3" tal:content="structure python:context.title.field(size=60)">title</td>
</tr>
<tr>
- <th nowrap>Priority</th>
+ <th>Priority</th>
<td tal:content="structure context/priority/menu">priority</td>
- <th nowrap>Status</th>
+ <th>Status</th>
<td tal:content="structure context/status/menu">status</td>
</tr>
<tr>
- <th nowrap>Superseder</th>
+ <th>Superseder</th>
<td>
<span tal:replace="structure python:context.superseder.field(showid=1, size=20)" />
<span tal:replace="structure python:db.issue.classhelp('id,title')" />
<br>View: <span tal:replace="structure python:context.superseder.link(showid=1)" />
</span>
</td>
- <th nowrap>Nosy List</th>
+ <th>Nosy List</th>
<td>
<span tal:replace="structure context/nosy/field" />
<span tal:replace="structure python:db.user.classhelp('username,realname,address,phone')" />
</tr>
<tr>
- <th nowrap>Assigned To</th>
+ <th>Assigned To</th>
<td tal:content="structure context/assignedto/menu">
assignedto menu
</td>
</tr>
<tr>
- <th nowrap>Change Note</th>
+ <th>Change Note</th>
<td colspan="3">
<textarea name=":note" wrap="hard" rows="5" cols="60"></textarea>
</td>
</tr>
<tr>
- <th nowrap>File</th>
+ <th>File</th>
<td colspan="3"><input type="file" name=":file" size="40"></td>
</tr>
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
========
to the form, a new category will be created with that name::
<tr>
- <th nowrap>Name</th>
+ <th>Name</th>
<td tal:content="structure python:context.name.field(size=60)">
name</td>
</tr>
<form method="POST" onSubmit="return submit_once()"
enctype="multipart/form-data">
- <input type="hidden" name="@required" value="name">
-
<table class="form">
<tr><th class="header" colspan="2">Category</th></tr>
<tr>
- <th nowrap>Name</th>
+ <th>Name</th>
<td tal:content="structure python:context.name.field(size=60)">
name</td>
</tr>
<tr>
- <td> </td>
+ <td>
+
+ <input type="hidden" name="@required" value="name">
+ </td>
<td colspan="3" tal:content="structure context/submit">
submit button will go here
</td>
table to lay things out. It doesn't matter where in the table we add new
stuff, it is entirely up to your sense of aesthetics::
- <th nowrap>Category</th>
+ <th>Category</th>
<td><span tal:replace="structure context/category/field" />
<span tal:replace="structure db/category/classhelp" />
</td>
4. in the ``issue.item.html`` template, change the status editing bit
from::
- <th nowrap>Status</th>
+ <th>Status</th>
<td tal:content="structure context/status/menu">status</td>
to::
- <th nowrap>Status</th>
+ <th>Status</th>
<td>
<select tal:condition="context/id" name="status">
<tal:block tal:define="ok context/status/transitions"
<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 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">
<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">
+ <input type="hidden" name="@action" value="page1_submit">
<strong>Category:</strong>
<tal:block tal:replace="structure context/category/menu" />
</form>
Note that later in the form, I test the value of "cat" include form
- elements that are appropriate. For example::
+ elements that are appropriate. For exsample::
<tal:block tal:condition="python:cat in '6 10 13 14 15 16 17'.split()">
<tr>
- <th nowrap>Operating System</th>
+ <th>Operating System</th>
<td tal:content="structure context/os/field"></td>
</tr>
<tr>
- <th nowrap>Web Browser</th>
+ <th>Web Browser</th>
<td tal:content="structure context/browser/field"></td>
</tr>
</tal:block>
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::
+ 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'),
+ ('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).
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.
field to capture a new timelog item's perdiod::
<tr>
- <th nowrap>Time Log</th>
+ <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>
''' Call me with a list of timelog items (which have an
Interval "period" property)
'''
- total = Interval('')
+ total = Interval('0d')
for time in times:
total += time.period._value
return total
- Replace the ``pass`` line as we did in step 4 above with the Client
+ 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.
+ ``totalTimeSpent`` method via the ``utils`` variable in our templates.
5. Display the time log for an issue::
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
<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 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>
<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 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>
2. Add the new "blockers" property to the issue.item edit page, using
something like::
- <th nowrap>Waiting On</th>
+ <th>Waiting On</th>
<td>
<span tal:replace="structure python:context.blockers.field(showid=1,
size=20)" />
history at the bottom of the issue page - look for a "link" event to
another issue's "blockers" property.
+Add users to the nosy list based on the topic
+---------------------------------------------
+
+We need the ability to automatically add users to the nosy list based
+on the occurence of a topic. Every user should be allowed to edit his
+own list of topics for which he wants to be added to the nosy list.
+
+Below will be showed that such a change can be performed with only
+minimal understanding of the roundup system, but with clever use
+of Copy and Paste.
+
+This requires three changes to the tracker: a change in the database to
+allow per-user recording of the lists of topics for which he wants to
+be put on the nosy list, a change in the user view allowing to edit
+this list of topics, and addition of an auditor which updates the nosy
+list when a topic is set.
+
+Adding the nosy topic list
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The change in the database to make is that for any user there should be
+a list of topics for which he wants to be put on the nosy list. Adding
+a ``Multilink`` of ``keyword`` seem to fullfill this (note that within
+the code topics are called ``keywords``.) As such, all what has to be
+done is to add a new field to the definition of ``user`` within the
+file ``dbinit.py``. We will call this new field ``nosy_keywords``, and
+the updated definition of user will be::
+
+ user = Class(db, "user",
+ username=String(), password=Password(),
+ address=String(), realname=String(),
+ phone=String(), organisation=String(),
+ alternate_addresses=String(),
+ queries=Multilink('query'), roles=String(),
+ timezone=String(),
+ 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
+to the user view, as is generated by the file ``html/user.item.html``.
+We easily can
+see that the topic field in the issue view has very similar editting
+requirements as our nosy topics, both being a list of topics. As
+such, we search for Topics in ``issue.item.html``, and extract the
+associated parts from there. We add this to ``user.item.html`` at the
+bottom of the list of viewed items (i.e. just below the 'Alternate
+E-mail addresses' in the classic template)::
+
+ <tr>
+ <th>Nosy Topics</th>
+ <td>
+ <span tal:replace="structure context/nosy_keywords/field" />
+ <span tal:replace="structure python:db.keyword.classhelp(property='nosy_keywords')" />
+ </td>
+ </tr>
+
+
+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.
+The choice is made to perform this action when the topics on an
+item are set, including when an item is created.
+Here we choose to start out with a copy of the
+``detectors/nosyreaction.py`` detector, which we copy to the file
+``detectors/nosy_keyword_reaction.py``.
+This looks like a good start as it also adds users
+to the nosy list. A look through the code reveals that the
+``nosyreaction`` function actually is sending the e-mail, which
+we do not need. As such, we can change the init function to::
+
+ def init(db):
+ db.issue.audit('create', update_kw_nosy)
+ db.issue.audit('set', update_kw_nosy)
+
+After that we rename the ``updatenosy`` function to ``update_kw_nosy``.
+The first two blocks of code in that function relate to settings
+``current`` to a combination of the old and new nosy lists. This
+functionality is left in the new auditor. The following block of
+code, which in ``updatenosy`` handled adding the assignedto user(s)
+to the nosy list, should be replaced by a block of code to add the
+interested users to the nosy list. We choose here to loop over all
+new topics, than loop over all users,
+and assign the user to the nosy list when the topic in the user's
+nosy_keywords. The next part in ``updatenosy``, adding the author
+and/or recipients of a message to the nosy list, obviously is not
+relevant here and thus is deleted from the new auditor. The last
+part, copying the new nosy list to newvalues, does not have to be changed.
+This brings the following function::
+
+ def update_kw_nosy(db, cl, nodeid, newvalues):
+ '''Update the nosy list for changes to the topics
+ '''
+ # nodeid will be None if this is a new node
+ current = {}
+ if nodeid is None:
+ ok = ('new', 'yes')
+ else:
+ ok = ('yes',)
+ # old node, get the current values from the node if they haven't
+ # changed
+ if not newvalues.has_key('nosy'):
+ nosy = cl.get(nodeid, 'nosy')
+ for value in nosy:
+ if not current.has_key(value):
+ current[value] = 1
+
+ # if the nosy list changed in this transaction, init from the new value
+ if newvalues.has_key('nosy'):
+ nosy = newvalues.get('nosy', [])
+ for value in nosy:
+ if not db.hasnode('user', value):
+ continue
+ if not current.has_key(value):
+ current[value] = 1
+
+ # add users with topic in nosy_keywords to the nosy list
+ if newvalues.has_key('topic') and newvalues['topic'] is not None:
+ topic_ids = newvalues['topic']
+ for topic in topic_ids:
+ # loop over all users,
+ # and assign user to nosy when topic in nosy_keywords
+ for user_id in db.user.list():
+ nosy_kw = db.user.get(user_id, "nosy_keywords")
+ found = 0
+ for kw in nosy_kw:
+ if kw == topic:
+ found = 1
+ if found:
+ current[user_id] = 1
+
+ # that's it, save off the new nosy list
+ newvalues['nosy'] = current.keys()
+
+and these two function are the only ones needed in the file.
+
+TODO: update this example to use the find() Class method.
+
+Caveats
+~~~~~~~
+
+A few problems with the design here can be noted:
+
+Multiple additions
+ When a user, after automatic selection, is manually removed
+ from the nosy list, he again is added to the nosy list when the
+ topic list of the issue is updated. A better design might be
+ to only check which topics are new compared to the old list
+ of topics, and only add users when they have indicated
+ interest on a new topic.
+
+ The code could also be changed to only trigger on the create() event,
+ rather than also on the set() event, thus only setting the nosy list
+ when the issue is created.
+
+Scalability
+ In the auditor there is a loop over all users. For a site with
+ only few users this will pose no serious problem, however, with
+ many users this will be a serious performance bottleneck.
+ A way out will be to link from the topics to the users which
+ selected these topics a nosy topics. This will eliminate the
+ loop over all users.
+
+
+Adding action links to the index page
+-------------------------------------
+
+Add a column to the item.index.html template.
+
+Resolving the issue::
+
+ <a tal:attributes="href
+ string:issue${i/id}?:status=resolved&:action=edit">resolve</a>
+
+"Take" the issue::
+
+ <a tal:attributes="href
+ string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a>
+
+... and so on
+
+Users may only edit their issues
+--------------------------------
+
+Users registering themselves are granted Provisional access - meaning they
+have access to edit the issues they submit, but not others. We create a new
+Role called "Provisional User" which is granted to newly-registered users,
+and has limited access. One of the Permissions they have is the new "Edit
+Own" on issues (regular users have "Edit".) We back up the permissions with
+an auditor.
+
+First up, we create the new Role and Permission structure in
+``dbinit.py``::
+
+ # New users not approved by the admin
+ db.security.addRole(name='Provisional User',
+ description='New user registered via web or email')
+ p = db.security.addPermission(name='Edit Own', klass='issue',
+ description='Can only edit own issues')
+ db.security.addPermissionToRole('Provisional User', p)
+
+ # Assign the access and edit Permissions for issue to new users now
+ p = db.security.getPermission('View', 'issue')
+ db.security.addPermissionToRole('Provisional User', p)
+ p = db.security.getPermission('Edit', 'issue')
+ db.security.addPermissionToRole('Provisional User', p)
+
+ # and give the new users access to the web and email interface
+ p = db.security.getPermission('Web Access')
+ db.security.addPermissionToRole('Provisional User', p)
+ p = db.security.getPermission('Email Access')
+ db.security.addPermissionToRole('Provisional User', p)
+
+
+Then in the ``config.py`` we change the Role assigned to newly-registered
+users, replacing the existing ``'User'`` values::
+
+ NEW_WEB_USER_ROLES = 'Provisional User'
+ NEW_EMAIL_USER_ROLES = 'Provisional User'
+
+Finally we add a new *auditor* to the ``detectors`` directory called
+``provisional_user_auditor.py``::
+
+ def audit_provisionaluser(db, cl, nodeid, newvalues):
+ ''' New users are only allowed to modify their own issues.
+ '''
+ if (db.getuid() != cl.get(nodeid, 'creator')
+ and db.security.hasPermission('Edit Own', db.getuid(), cl.classname)):
+ raise ValueError, ('You are only allowed to edit your own %s'
+ % cl.classname)
+
+ def init(db):
+ # fire before changes are made
+ db.issue.audit('set', audit_provisionaluser)
+ db.issue.audit('retire', audit_provisionaluser)
+ db.issue.audit('restore', audit_provisionaluser)
+
+Note that some older trackers might also want to change the ``page.html``
+template as follows::
+
+ <p class="classblock"
+ - tal:condition="python:request.user.username != 'anonymous'">
+ + tal:condition="python:request.user.hasPermission('View', 'user')">
+ <b>Administration</b><br>
+ <tal:block tal:condition="python:request.user.hasPermission('Edit', None)">
+ <a href="home?:template=classlist">Class List</a><br>
+
+(note that the "-" indicates a removed line, and the "+" indicates an added
+line).
+
+
+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).
-------------------