Mercurial > p > roundup > code
diff doc/customizing.txt @ 1356:83f33642d220 maint-0.5
[[Metadata associated with this commit was garbled during conversion from CVS
to Subversion.]]
| author | Richard Jones <richard@users.sourceforge.net> |
|---|---|
| date | Thu, 09 Jan 2003 22:59:22 +0000 |
| parents | |
| children | 56c5b4509378 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/doc/customizing.txt Thu Jan 09 22:59:22 2003 +0000 @@ -0,0 +1,2791 @@ +=================== +Customising Roundup +=================== + +:Version: $Revision: 1.68 $ + +.. This document borrows from the ZopeBook section on ZPT. The original is at: + http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx + +.. contents:: + :depth: 1 + +What You Can Do +=============== + +Before you get too far, it's probably worth having a quick read of the Roundup +`design documentation`_. + +Customisation of Roundup can take one of five forms: + +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`_ +6. change the `web interface`_ + +The third case is special because it takes two distinctly different forms +depending upon whether the tracker has been initialised or not. The other two +may be done at any time, before or after tracker initialisation. Yes, this +includes adding or removing properties from classes. + + +Trackers in a Nutshell +====================== + +Trackers have the following structure: + +=================== ======================================================== +Tracker File Description +=================== ======================================================== +config.py Holds the basic `tracker configuration`_ +dbinit.py Holds the `tracker schema`_ +interfaces.py Defines the Web and E-Mail interfaces for the tracker +select_db.py Selects the database back-end for the tracker +db/ Holds the tracker's database +db/files/ Holds the tracker's upload files and messages +detectors/ Auditors and reactors for this tracker +html/ Web interface templates, images and style sheets +=================== ======================================================== + +Tracker Configuration +===================== + +The config.py located in your tracker home contains the basic configuration +for the web and e-mail components of roundup's interfaces. As the name +suggests, this file is a Python module. This means that any valid python +expression may be used in the file. Mostly though, you'll be setting the +configuration variables to string values. Python string values must be quoted +with either single or double quotes:: + + 'this is a string' + "this is also a string - use it when you have a 'single quote' in the value" + this is not a string - it's not quoted + +Python strings may use formatting that's almost identical to C string +formatting. The ``%`` operator is used to perform the formatting, like so:: + + 'roundup-admin@%s'%MAIL_DOMAIN + +this will create a string ``'roundup-admin@tracker.domain.example'`` if +MAIL_DOMAIN is set to ``'tracker.domain.example'``. + +You'll also note some values are set to:: + + os.path.join(TRACKER_HOME, 'db') + +or similar. This creates a new string which holds the path to the "db" +directory in the TRACKER_HOME directory. This is just a convenience so if the +TRACKER_HOME changes you don't have to edit multiple valoues. + +The configuration variables available are: + +**TRACKER_HOME** - ``os.path.split(__file__)[0]`` + The tracker home directory. The above default code will automatically + determine the tracker home for you, so you can just leave it alone. + +**MAILHOST** - ``'localhost'`` + The SMTP mail host that roundup will use to send e-mail. + +**MAIL_DOMAIN** - ``'tracker.domain.example'`` + The domain name used for email addresses. + +**DATABASE** - ``os.path.join(TRACKER_HOME, 'db')`` + This is the directory that the database is going to be stored in. By default + it is in the tracker home. + +**TEMPLATES** - ``os.path.join(TRACKER_HOME, 'html')`` + This is the directory that the HTML templates reside in. By default they are + in the tracker home. + +**TRACKER_NAME** - ``'Roundup issue tracker'`` + A descriptive name for your roundup tracker. This is sent out in e-mails and + appears in the heading of CGI pages. + +**TRACKER_EMAIL** - ``'issue_tracker@%s'%MAIL_DOMAIN`` + The email address that e-mail sent to roundup should go to. Think of it as the + tracker's personal e-mail address. + +**TRACKER_WEB** - ``'http://your.tracker.url.example/'`` + The web address that the tracker is viewable at. This will be included in + information sent to users of the tracker. The URL must include the cgi-bin + part or anything else that is required to get to the home page of the + tracker. You must include a trailing '/' in the URL. + +**ADMIN_EMAIL** - ``'roundup-admin@%s'%MAIL_DOMAIN`` + The email address that roundup will complain to if it runs into trouble. + +**MESSAGES_TO_AUTHOR** - ``'yes'`` or``'no'`` + Send nosy messages to the author of the message. + +**ADD_AUTHOR_TO_NOSY** - ``'new'``, ``'yes'`` or ``'no'`` + Does the author of a message get placed on the nosy list automatically? + If ``'new'`` is used, then the author will only be added when a message + creates a new issue. If ``'yes'``, then the author will be added on followups + too. If ``'no'``, they're never added to the nosy. + +**ADD_RECIPIENTS_TO_NOSY** - ``'new'``, ``'yes'`` or ``'no'`` + Do the recipients (To:, Cc:) of a message get placed on the nosy list? + If ``'new'`` is used, then the recipients will only be added when a message + creates a new issue. If ``'yes'``, then the recipients will be added on + followups too. If ``'no'``, they're never added to the nosy. + +**EMAIL_SIGNATURE_POSITION** - ``'top'``, ``'bottom'`` or ``'none'`` + Where to place the email signature in messages that Roundup generates. + +**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. + +**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'``. + +**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. + +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.:: + + # roundup home is this package's directory + TRACKER_HOME=os.path.split(__file__)[0] + + # The SMTP mail host that roundup will use to send mail + MAILHOST = 'localhost' + + # The domain name used for email addresses. + MAIL_DOMAIN = 'your.tracker.email.domain.example' + + # This is the directory that the database is going to be stored in + DATABASE = os.path.join(TRACKER_HOME, 'db') + + # This is the directory that the HTML templates reside in + TEMPLATES = os.path.join(TRACKER_HOME, 'html') + + # A descriptive name for your roundup tracker + TRACKER_NAME = 'Roundup issue tracker' + + # The email address that mail to roundup should go to + TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN + + # The web address that the tracker is viewable at + TRACKER_WEB = 'http://your.tracker.url.example/' + + # The email address that roundup will complain to if it runs into trouble + ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN + + # Send nosy messages to the author of the message + MESSAGES_TO_AUTHOR = 'no' # either 'yes' or 'no' + + # Does the author of a message get placed on the nosy list automatically? + # If 'new' is used, then the author will only be added when a message + # creates a new issue. If 'yes', then the author will be added on followups + # too. If 'no', they're never added to the nosy. + ADD_AUTHOR_TO_NOSY = 'new' # one of 'yes', 'no', 'new' + + # Do the recipients (To:, Cc:) of a message get placed on the nosy list? + # If 'new' is used, then the recipients will only be added when a message + # creates a new issue. If 'yes', then the recipients will be added on followups + # too. If 'no', they're never added to the nosy. + ADD_RECIPIENTS_TO_NOSY = 'new' # either 'yes', 'no', 'new' + + # Where to place the email signature + EMAIL_SIGNATURE_POSITION = 'bottom' # one of 'top', 'bottom', 'none' + + # Keep email citations + EMAIL_KEEP_QUOTED_TEXT = 'no' # either 'yes' or 'no' + + # Preserve the email body as is + EMAIL_LEAVE_BODY_UNCHANGED = 'no' # either 'yes' or 'no' + + # 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. + # Examples: + MAIL_DEFAULT_CLASS = 'issue' # use "issue" class by default + #MAIL_DEFAULT_CLASS = '' # disable (or just comment the var out) + +Tracker Schema +============== + +Note: if you modify the schema, you'll most likely need to edit the + `web interface`_ HTML template files and `detectors`_ to reflect + your changes. + +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:: + + pri = Class(db, "priority", name=String(), order=String()) + pri.setkey("name") + + stat = Class(db, "status", name=String(), order=String()) + stat.setkey("name") + + keyword = Class(db, "keyword", name=String()) + keyword.setkey("name") + + user = Class(db, "user", username=String(), organisation=String(), + password=String(), address=String(), realname=String(), phone=String()) + user.setkey("username") + + msg = FileClass(db, "msg", author=Link("user"), summary=String(), + date=Date(), recipients=Multilink("user"), files=Multilink("file")) + + file = FileClass(db, "file", name=String(), type=String()) + + issue = IssueClass(db, "issue", topic=Multilink("keyword"), + status=Link("status"), assignedto=Link("user"), + priority=Link("priority")) + issue.setkey('title') + +Classes and Properties - creating a new information store +--------------------------------------------------------- + +In the tracker above, we've defined 7 classes of information: + + priority + Defines the possible levels of urgency for issues. + + status + Defines the possible states of processing the issue may be in. + + keyword + Initially empty, will hold keywords useful for searching issues. + + user + Initially holding the "admin" user, will eventually have an entry for all + users using roundup. + + msg + Initially empty, will all e-mail messages sent to or generated by + roundup. + + file + Initially empty, will all files attached to issues. + + issue + Initially empty, this is where the issue information is stored. + +We define the "priority" and "status" classes to allow two things: reduction in +the amount of information stored on the issue and more powerful, accurate +searching of issues by priority and status. By only requiring a link on the +issue (which is stored as a single number) we reduce the chance that someone +mis-types a priority or status - or simply makes a new one up. + +Class and Items +~~~~~~~~~~~~~~~ + +A Class defines a particular class (or type) of data that will be stored in the +database. A class comprises one or more properties, which given the information +about the class items. +The actual data entered into the database, using class.create() are called +items. They have a special immutable property called id. We sometimes refer to +this as the itemid. + +Properties +~~~~~~~~~~ + +A Class is comprised of one or more properties of the following types: + +* String properties are for storing arbitrary-length strings. +* Password properties are for storing encoded arbitrary-length strings. The + default encoding is defined on the roundup.password.Password class. +* Date properties store date-and-time stamps. Their values are Timestamp + objects. +* Number properties store numeric values. +* Boolean properties store on/off, yes/no, true/false values. +* A Link property refers to a single other item selected from a specified + class. The class is part of the property; the value is an integer, the id + of the chosen item. +* A Multilink property refers to possibly many items in a specified class. + The value is a list of integers. + +FileClass +~~~~~~~~~ + +FileClasses save their "content" attribute off in a separate file from the rest +of the database. This reduces the number of large entries in the database, +which generally makes databases more efficient, and also allows us to use +command-line tools to operate on the files. They are stored in the files sub- +directory of the db directory in your tracker. + +IssueClass +~~~~~~~~~~ + +IssueClasses automatically include the "messages", "files", "nosy", and +"superseder" properties. +The messages and files properties list the links to the messages and files +related to the issue. The nosy property is a list of links to users who wish to +be informed of changes to the issue - they get "CC'ed" e-mails when messages +are sent to or generated by the issue. The nosy reactor (in the detectors +directory) handles this action. The superceder link indicates an issue which +has superceded this one. +They also have the dynamically generated "creation", "activity" and "creator" +properties. +The value of the "creation" property is the date when an item was created, and +the value of the "activity" property is the date when any property on the item +was last edited (equivalently, these are the dates on the first and last +records in the item's journal). The "creator" property holds a link to the user +that created the issue. + +setkey(property) +~~~~~~~~~~~~~~~~ + +Select a String property of the class to be the key property. The key property +muse be unique, and allows references to the items in the class by the content +of the key property. That is, we can refer to users by their username, e.g. +let's say that there's an issue in roundup, issue 23. There's also a user, +richard who happens to be user 2. To assign an issue to him, we could do either +of:: + + roundup-admin set issue assignedto=2 + +or:: + + roundup-admin set issue assignedto=richard + +Note, the same thing can be done in the web and e-mail interfaces. + +create(information) +~~~~~~~~~~~~~~~~~~~ + +Create an item in the database. This is generally used to create items in the +"definitional" classes like "priority" and "status". + + +Examples of adding to your schema +--------------------------------- + +TODO + + +Detectors - adding behaviour to your tracker +============================================ +.. _detectors: + +Detectors are initialised every time you open your tracker database, so you're +free to add and remove them any time, even after the database is initliased +via the "roundup-admin initalise" command. + +The detectors in your tracker fire before (*auditors*) and after (*reactors*) +changes to the contents of your database. They are Python modules that sit in +your tracker's ``detectors`` directory. You will have some installed by +default - have a look. You can write new detectors or modify the existing +ones. The existing detectors installed for you are: + +**nosyreaction.py** + This provides the automatic nosy list maintenance and email sending. The nosy + reactor (``nosyreaction``) fires when new messages are added to issues. + The nosy auditor (``updatenosy``) fires when issues are changed and figures + what changes need to be made to the nosy list (like adding new authors etc) +**statusauditor.py** + This provides the ``chatty`` auditor which changes the issue status from + ``unread`` or ``closed`` to ``chatting`` if new messages appear. It also + provides the ``presetunread`` auditor which pre-sets the status to + ``unread`` on new items if the status isn't explicitly defined. + +See the detectors section in the `design document`__ for details of the +interface for detectors. + +__ design.html + +Sample additional detectors that have been found useful will appear in the +``detectors`` directory of the Roundup distribution: + +**newissuecopy.py** + This detector sends an email to a team address whenever a new issue is + 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! + + The detector code:: + + from roundup import roundupdb + + 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 + + def init(db): + db.issue.react('create', newissuecopy) + + +Database Content +================ + +Note: if you modify the content of definitional classes, you'll most likely + need to edit the tracker `detectors`_ to reflect your changes. + +Customisation of the special "definitional" classes (eg. status, priority, +resolution, ...) may be done either before or after the tracker is +initialised. The actual method of doing so is completely different in each +case though, so be careful to use the right one. + +**Changing content before tracker initialisation** + Edit the dbinit module in your tracker to alter the items created in using + the create() methods. + +**Changing content after tracker initialisation** + As the "admin" user, click on the "class list" link in the web interface + to bring up a list of all database classes. Click on the name of the class + you wish to change the content of. + + You may also use the roundup-admin interface's create, set and retire + methods to add, alter or remove items from the classes in question. + +See "`adding a new field to the classic schema`_" for an example that requires +database content changes. + + +Access Controls +=============== + +A set of Permissions are built in to the security module by default: + +- Edit (everything) +- View (everything) + +The default interfaces define: + +- Web Registration +- Web Access +- Web Roles +- Email Registration +- Email Access + +These are hooked into the default Roles: + +- Admin (Edit everything, View everything, Web Roles) +- User (Web Access, Email Access) +- Anonymous (Web Registration, Email Registration) + +And finally, the "admin" user gets the "Admin" Role, and the "anonymous" user +gets the "Anonymous" assigned when the database is initialised on installation. +The two default schemas then define: + +- Edit issue, View issue (both) +- Edit file, View file (both) +- Edit msg, View msg (both) +- Edit support, View support (extended only) + +and assign those Permissions to the "User" Role. Put together, these settings +appear in the ``open()`` function of the tracker ``dbinit.py`` (the following +is taken from the "minimal" template ``dbinit.py``):: + + # + # SECURITY SETTINGS + # + # new permissions for this schema + for cl in ('user', ): + db.security.addPermission(name="Edit", klass=cl, + description="User is allowed to edit "+cl) + db.security.addPermission(name="View", klass=cl, + description="User is allowed to access "+cl) + + # and give the regular users access to the web and email interface + p = db.security.getPermission('Web Access') + db.security.addPermissionToRole('User', p) + p = db.security.getPermission('Email Access') + db.security.addPermissionToRole('User', p) + + # May users view other user information? Comment these lines out + # if you don't want them to + p = db.security.getPermission('View', 'user') + db.security.addPermissionToRole('User', p) + + # Assign the appropriate permissions to the anonymous user's Anonymous + # Role. Choices here are: + # - Allow anonymous users to register through the web + p = db.security.getPermission('Web Registration') + db.security.addPermissionToRole('Anonymous', p) + # - Allow anonymous (new) users to register through the email gateway + p = db.security.getPermission('Email Registration') + db.security.addPermissionToRole('Anonymous', p) + + +New User Roles +-------------- + +New users are assigned the Roles defined in the config file as: + +- NEW_WEB_USER_ROLES +- NEW_EMAIL_USER_ROLES + + +Changing Access Controls +------------------------ + +You may alter the configuration variables to change the Role that new web or +email users get, for example to not give them access to the web interface if +they register through email. + +You may use the ``roundup-admin`` "``security``" command to display the +current Role and Permission configuration in your tracker. + +Adding a new Permission +~~~~~~~~~~~~~~~~~~~~~~~ + +When adding a new Permission, you will need to: + +1. add it to your tracker's dbinit so it is created +2. enable it for the Roles that should have it (verify with + "``roundup-admin security``") +3. add it to the relevant HTML interface templates +4. add it to the appropriate xxxPermission methods on in your tracker + interfaces module + +Example Scenarios +~~~~~~~~~~~~~~~~~ + +**automatic registration of users in the e-mail gateway** + By giving the "anonymous" user the "Email Registration" Role, any + unidentified user will automatically be registered with the tracker (with + no password, so they won't be able to log in through the web until an admin + sets them a password). Note: this is the default behaviour in the tracker + templates that ship with Roundup. + +**anonymous access through the e-mail gateway** + Give the "anonymous" user the "Email Access" and ("Edit", "issue") Roles + but not giving them the "Email Registration" Role. This means that when an + unknown user sends email into the tracker, they're automatically logged in + as "anonymous". Since they don't have the "Email Registration" Role, they + won't be automatically registered, but since "anonymous" has permission + to use the gateway, they'll still be able to submit issues. Note that the + Sender information - their email address - will not be available - they're + *anonymous*. + +**only developers may be assigned issues** + Create a new Permission called "Fixer" for the "issue" class. Create a new + Role "Developer" which has that Permission, and assign that to the + appropriate users. Filter the list of users available in the assignedto + list to include only those users. Enforce the Permission with an auditor. See + the example `restricting the list of users that are assignable to a task`_. + +**only managers may sign off issues as complete** + Create a new Permission called "Closer" for the "issue" class. Create a new + Role "Manager" which has that Permission, and assign that to the appropriate + users. In your web interface, only display the "resolved" issue state option + when the user has the "Closer" Permissions. Enforce the Permission with + an auditor. This is very similar to the previous example, except that the + web interface check would look like:: + + <option tal:condition="python:request.user.hasPermission('Closer')" + value="resolved">Resolved</option> + +**don't give users who register through email web access** + Create a new Role called "Email User" which has all the Permissions of the + normal "User" Role minus the "Web Access" Permission. This will allow users + to send in emails to the tracker, but not access the web interface. + +**let some users edit the details of all users** + Create a new Role called "User Admin" which has the Permission for editing + users:: + + db.security.addRole(name='User Admin', description='Managing users') + p = db.security.getPermission('Edit', 'user') + db.security.addPermissionToRole('User Admin', p) + + and assign the Role to the users who need the permission. + + +Web Interface +============= + +.. contents:: + :local: + :depth: 1 + +The web is provided by the roundup.cgi.client module and is used by +roundup.cgi, roundup-server and ZRoundup. +In all cases, we determine which tracker is being accessed +(the first part of the URL path inside the scope of the CGI handler) and pass +control on to the tracker interfaces.Client class - which uses the Client class +from roundup.cgi.client - which handles the rest of +the access through its main() method. This means that you can do pretty much +anything you want as a web interface to your tracker. + +Repurcussions of changing the tracker schema +--------------------------------------------- + +If you choose to change the `tracker schema`_ you will need to ensure the web +interface knows about it: + +1. Index, item and search pages for the relevant classes may need to have + properties added or removed, +2. The "page" template may require links to be changed, as might the "home" + page's content arguments. + +How requests are processed +-------------------------- + +The basic processing of a web request proceeds as follows: + +1. figure out who we are, defaulting to the "anonymous" user +2. figure out what the request is for - we call this the "context" +3. handle any requested action (item edit, search, ...) +4. render the template requested by the context, resulting in HTML output + +In some situations, exceptions occur: + +- HTTP Redirect (generally raised by an action) +- SendFile (generally raised by determine_context) + here we serve up a FileClass "content" property +- SendStaticFile (generally raised by determine_context) + here we serve up a file from the tracker "html" directory +- Unauthorised (generally raised by an action) + here the action is cancelled, the request is rendered and an error + message is displayed indicating that permission was not + granted for the action to take place +- NotFound (raised wherever it needs to be) + this exception percolates up to the CGI interface that called the client + +Determining web context +----------------------- + +To determine the "context" of a request, we look at the URL and the special +request variable ``:template``. The URL path after the tracker identifier +is examined. Typical URL paths look like: + +1. ``/tracker/issue`` +2. ``/tracker/issue1`` +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 +"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, + "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 identifies + the tracker class we're to display. +d. if the path is an item designator (as in examples 2 and 4, "issue1" and + "file1"), then we're to display a specific item. +e. if the path starts with an item designator and is longer than + one entry (as in example 5, "file1/kitten.png"), then we're assumed + to be handling an item of a + FileClass, and the extra path information gives the filename + that the client is going to label the download with (ie + "file1/kitten.png" is nicer to download than "file1"). This + raises a SendFile exception. + +Both b. and e. stop before we bother to +determine the template we're going to use. That's because they +don't actually use templates. + +The template used is specified by the ``:template`` CGI variable, +which defaults to: + +- only classname suplied: "index" +- full item designator supplied: "item" + + +Performing actions in web requests +---------------------------------- + +When a user requests a web page, they may optionally also request for an +action to take place. As described in `how requests are processed`_, the +action is performed before the requested page is generated. Actions are +triggered by using a ``:action`` CGI variable, where the value is one of: + +**login** + Attempt to log a user in. + +**logout** + Log the user out - make them "anonymous". + +**register** + Attempt to create a new user based on the contents of the form and then log + them in. + +**edit** + Perform an edit of an item in the database. There are some special form + elements you may use: + + :link=designator:property and :multilink=designator:property + The value specifies an item designator and the property on that + item to add *this* item to as a link or multilink. + :note + Create a message and attach it to the current item's + "messages" property. + :file + Create a file and attach it to the current item's + "files" property. Attach the file to the message created from + the :note if it's supplied. + :required=property,property,... + The named properties are required to be filled in the form. + :remove:<propname>=id(s) + The ids will be removed from the multilink property. You may have multiple + :remove:<propname> form elements for a single <propname>. + :add:<propname>=id(s) + The ids will be added to the multilink property. You may have multiple + :add:<propname> form elements for a single <propname>. + +**new** + Add a new item to the database. You may use the same special form elements + as in the "edit" action. + +**retire** + Retire the item in the database. + +**editCSV** + Performs an edit of all of a class' items in one go. See also the + *class*.csv templating method which generates the CSV data to be edited, and + the "_generic.index" template which uses both of these features. + +**search** + Mangle some of the form variables. + + Set the form ":filter" variable based on the values of the + filter variables - if they're set to anything other than + "dontcare" then add them to :filter. + + 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 in your tracker 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 are: + +**login** + Determine whether the user has permission to log in. + Base behaviour is to check the user has "Web Access". +**logout** + No permission checks are made. +**register** + Determine whether the user has permission to register + Base behaviour is to check the user has "Web Registration". +**edit** + Determine whether the user has permission to edit this item. + Base behaviour is to check the user can edit this class. If we're + editing the "user" class, users are allowed to edit their own + details. Unless it's the "roles" property, which requires the + special Permission "Web Roles". +**new** + Determine whether the user has permission to create (edit) this item. + Base behaviour is to check the user can edit this class. No + additional property checks are made. Additionally, new user items + may be created if the user has the "Web Registration" Permission. +**editCSV** + Determine whether the user has permission to edit this class. + Base behaviour is to check the user can edit this class. +**search** + Determine whether the user has permission to search this class. + Base behaviour is to check the user can view this class. + + +Default templates +----------------- + +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: + +**page** + This template usually defines the overall look of your tracker. When you + view an issue, it appears inside this template. When you view an index, it + also appears inside this template. This template defines a macro called + "icing" which is used by almost all other templates as a coating for their + content, using its "content" slot. It will also define the "head_title" + and "body_title" slots to allow setting of the page title. +**home** + the default page displayed when no other page is indicated by the user +**home.classlist** + a special version of the default page that lists the classes in the tracker +**classname.item** + displays an item of the *classname* class +**classname.index** + displays a list of *classname* items +**classname.search** + displays a search page for *classname* items +**_generic.index** + used to display a list of items where there is no *classname*.index available +**_generic.help** + used to display a "class help" page where there is no *classname*.help +**user.register** + a special page just for the user class that renders the registration page +**style.css** + a static file that is served up as-is + +Note: Remember that you can create any template extension you want to, so +if you just want to play around with the templating for new issues, you can +copy the current "issue.item" template to "issue.test", and then access the +test template using the ":template" URL argument:: + + http://your.tracker.example/tracker/issue?:template=test + +and it won't affect your users using the "issue.item" template. + + +How the templates work +---------------------- + +Basic Templating Actions +~~~~~~~~~~~~~~~~~~~~~~~~ + +Roundup's templates consist of special attributes on your template tags. +These attributes form the Template Attribute Language, or TAL. The basic tag +commands are: + +**tal:define="variable expression; variable expression; ..."** + Define a new variable that is local to this tag and its contents. For + example:: + + <html tal:define="title request/description"> + <head><title tal:content="title"></title></head> + </html> + + In the example, the variable "title" is defined as being the result of the + expression "request/description". The tal:content command inside the <html> + tag may then use the "title" variable. + +**tal:condition="expression"** + Only keep this tag and its contents if the expression is true. For example:: + + <p tal:condition="python:request.user.hasPermission('View', 'issue')"> + Display some issue information. + </p> + + In the example, the <p> tag and its contents are only displayed if the + user has the View permission for issues. We consider the number zero, a + blank string, an empty list, and the built-in variable nothing to be false + values. Nearly every other value is true, including non-zero numbers, and + strings with anything in them (even spaces!). + +**tal:repeat="variable expression"** + Repeat this tag and its contents for each element of the sequence that the + expression returns, defining a new local variable and a special "repeat" + variable for each element. For example:: + + <tr tal:repeat="u user/list"> + <td tal:content="u/id"></td> + <td tal:content="u/username"></td> + <td tal:content="u/realname"></td> + </tr> + + The example would iterate over the sequence of users returned by + "user/list" and define the local variable "u" for each entry. + +**tal:replace="expression"** + Replace this tag with the result of the expression. For example:: + + <span tal:replace="request/user/realname"></span> + + The example would replace the <span> tag and its contents with the user's + realname. If the user's realname was "Bruce" then the resultant output + would be "Bruce". + +**tal:content="expression"** + Replace the contents of this tag with the result of the expression. For + example:: + + <span tal:content="request/user/realname">user's name appears here</span> + + The example would replace the contents of the <span> tag with the user's + realname. If the user's realname was "Bruce" then the resultant output + would be "<span>Bruce</span>". + +**tal:attributes="attribute expression; attribute expression; ..."** + Set attributes on this tag to the results of expressions. For example:: + + <a tal:attributes="href string:user${request/user/id}">My Details</a> + + In the example, the "href" attribute of the <a> tag is set to the value of + the "string:user${request/user/id}" expression, which will be something + like "user123". + +**tal:omit-tag="expression"** + Remove this tag (but not its contents) if the expression is true. For + example:: + + <span tal:omit-tag="python:1">Hello, world!</span> + + would result in output of:: + + Hello, world! + +Note that the commands on a given tag are evaulated in the order above, so +*define* comes before *condition*, and so on. + +Additionally, a tag is defined, tal:block, which is removed from output. Its +content is not, but the tag itself is (so don't go using any tal:attributes +commands on it). This is useful for making arbitrary blocks of HTML +conditional or repeatable (very handy for repeating multiple table rows, +which would othewise require an illegal tag placement to effect the repeat). + + +Templating Expressions +~~~~~~~~~~~~~~~~~~~~~~ + +The expressions you may use in the attibute values may be one of the following +forms: + +**Path Expressions** - eg. ``item/status/checklist`` + These are object attribute / item accesses. Roughly speaking, the path + ``item/status/checklist`` is broken into parts ``item``, ``status`` + and ``checklist``. The ``item`` part is the root of the expression. + We then look for a ``status`` attribute on ``item``, or failing that, a + ``status`` item (as in ``item['status']``). If that + fails, the path expression fails. When we get to the end, the object we're + left with is evaluated to get a string - methods are called, objects are + stringified. Path expressions may have an optional ``path:`` prefix, though + they are the default expression type, so it's not necessary. + + If an expression evaluates to ``default`` then the expression is + "cancelled" - whatever HTML already exists in the template will remain + (tag content in the case of tal:content, attributes in the case of + tal:attributes). + + If an expression evaluates to ``nothing`` then the target of the expression + is removed (tag content in the case of tal:content, attributes in the case + of tal:attributes and the tag itself in the case of tal:replace). + + If an element in the path may not exist, then you can use the ``|`` + operator in the expression to provide an alternative. So, the expression + ``request/form/foo/value | default`` would simply leave the current HTML + in place if the "foo" form variable doesn't exist. + +**String Expressions** - eg. ``string:hello ${user/name}`` + These expressions are simple string interpolations (though they can be just + plain strings with no interpolation if you want. The expression in the + ``${ ... }`` is just a path expression as above. + +**Python Expressions** - eg. ``python: 1+1`` + These expressions give the full power of Python. All the "root level" + variables are available, so ``python:item.status.checklist()`` would be + equivalent to ``item/status/checklist``, assuming that ``checklist`` is + a method. + +Template Macros +~~~~~~~~~~~~~~~ + +Macros are used in Roundup to save us from repeating the same common page +stuctures over and over. The most common (and probably only) macro you'll use +is the "icing" macro defined in the "page" template. + +Macros are generated and used inside your templates using special attributes +similar to the `basic templating actions`_. In this case though, the +attributes belong to the Macro Expansion Template Attribute Language, or +METAL. The macro commands are: + +**metal:define-macro="macro name"** + Define that the tag and its contents are now a macro that may be inserted + into other templates using the *use-macro* command. For example:: + + <html metal:define-macro="page"> + ... + </html> + + defines a macro called "page" using the ``<html>`` tag and its contents. + Once defined, macros are stored on the template they're defined on in the + ``macros`` attribute. You can access them later on through the ``templates`` + variable, eg. the most common ``templates/page/macros/icing`` to access the + "page" macro of the "page" template. + +**metal:use-macro="path expression"** + Use a macro, which is identified by the path expression (see above). This + will replace the current tag with the identified macro contents. For + example:: + + <tal:block metal:use-macro="templates/page/macros/icing"> + ... + </tal:block> + + will replace the tag and its contents with the "page" macro of the "page" + template. + +**metal:define-slot="slot name"** and **metal:fill-slot="slot name"** + To define *dynamic* parts of the macro, you define "slots" which may be + filled when the macro is used with a *use-macro* command. For example, the + ``templates/page/macros/icing`` macro defines a slot like so:: + + <title metal:define-slot="head_title">title goes here</title> + + In your *use-macro* command, you may now use a *fill-slot* command like + this:: + + <title metal:fill-slot="head_title">My Title</title> + + where the tag that fills the slot completely replaces the one defined as + the slot in the macro. + +Note that you may not mix METAL and TAL commands on the same tag, but TAL +commands may be used freely inside METAL-using tags (so your *fill-slots* +tags may have all manner of TAL inside them). + + +Information available to templates +---------------------------------- + +Note: this is implemented by roundup.cgi.templating.RoundupPageTemplate + +The following variables are available to templates. + +**context** + The current context. This is either None, a + `hyperdb class wrapper`_ or a `hyperdb item wrapper`_ +**request** + Includes information about the current request, including: + - the current index information (``filterspec``, ``filter`` args, + ``properties``, etc) parsed out of the form. + - methods for easy filterspec link generation + - *user*, the current user item as an HTMLItem instance + - *form* + The current CGI form information as a mapping of form argument + name to value +**config** + This variable holds all the values defined in the tracker config.py file + (eg. TRACKER_NAME, etc.) +**db** + The current database, used to access arbitrary database items. +**templates** + Access to all the tracker templates by name. Used mainly in *use-macro* + commands. +**utils** + This variable makes available some utility functions like batching. +**nothing** + This is a special variable - if an expression evaluates to this, then the + tag (in the case of a tal:replace), its contents (in the case of + tal:content) or some attributes (in the case of tal:attributes) will not + appear in the the output. So for example:: + + <span tal:attributes="class nothing">Hello, World!</span> + + would result in:: + + <span>Hello, World!</span> + +**default** + Also a special variable - if an expression evaluates to this, then the + existing HTML in the template will not be replaced or removed, it will + remain. So:: + + <span tal:replace="default">Hello, World!</span> + + would result in:: + + <span>Hello, World!</span> + +The context variable +~~~~~~~~~~~~~~~~~~~~ + +The *context* variable is one of three things based on the current context +(see `determining web context`_ for how we figure this out): + +1. if we're looking at a "home" page, then it's None +2. if we're looking at a specific hyperdb class, it's a + `hyperdb class wrapper`_. +3. if we're looking at a specific hyperdb item, it's a + `hyperdb item wrapper`_. + +If the context is not None, we can access the properties of the class or item. +The only real difference between cases 2 and 3 above are: + +1. the properties may have a real value behind them, and this will appear if + the property is displayed through ``context/property`` or + ``context/property/field``. +2. the context's "id" property will be a false value in the second case, but + a real, or true value in the third. Thus we can determine whether we're + looking at a real item from the hyperdb by testing "context/id". + +Hyperdb class wrapper +::::::::::::::::::::: + +Note: this is implemented by the roundup.cgi.templating.HTMLClass class. + +This wrapper object provides access to a hyperb class. It is used primarily +in both index view and new item views, but it's also usable anywhere else that +you wish to access information about a class, or the items of a class, when +you don't have a specific item of that class in mind. + +We allow access to properties. There will be no "id" property. The value +accessed through the property will be the current value of the same name from +the CGI form. + +There are several methods available on these wrapper objects: + +=========== ============================================================= +Method Description +=========== ============================================================= +properties return a `hyperdb property wrapper`_ for all of this class' + properties. +list lists all of the active (not retired) items in the class. +csv return the items of this class as a chunk of CSV text. +propnames lists the names of the properties of this class. +filter lists of items from this class, filtered and sorted + by the current *request* filterspec/filter/sort/group args +classhelp display a link to a javascript popup containing this class' + "help" template. +submit generate a submit button (and action hidden element) +renderWith render this class with the given template. +history returns 'New node - no history' :) +is_edit_ok is the user allowed to Edit the current class? +is_view_ok is the user allowed to View the current class? +=========== ============================================================= + +Note that if you have a property of the same name as one of the above methods, +you'll need to access it using a python "item access" expression. For example:: + + python:context['list'] + +will access the "list" property, rather than the list method. + + +Hyperdb item wrapper +:::::::::::::::::::: + +Note: this is implemented by the roundup.cgi.templating.HTMLItem class. + +This wrapper object provides access to a hyperb item. + +We allow access to properties. There will be no "id" property. The value +accessed through the property will be the current value of the same name from +the CGI form. + +There are several methods available on these wrapper objects: + +=============== ============================================================= +Method Description +=============== ============================================================= +submit generate a submit button (and action hidden element) +journal return the journal of the current item (**not implemented**) +history render the journal of the current item as HTML +renderQueryForm specific to the "query" class - render the search form for + the query +hasPermission specific to the "user" class - determine whether the user + has a Permission +is_edit_ok is the user allowed to Edit the current item? +is_view_ok is the user allowed to View the current item? +=============== ============================================================= + + +Note that if you have a property of the same name as one of the above methods, +you'll need to access it using a python "item access" expression. For example:: + + python:context['journal'] + +will access the "journal" property, rather than the journal method. + + +Hyperdb property wrapper +:::::::::::::::::::::::: + +Note: this is implemented by subclasses roundup.cgi.templating.HTMLProperty +class (HTMLStringProperty, HTMLNumberProperty, and so on). + +This wrapper object provides access to a single property of a class. Its +value may be either: + +1. if accessed through a `hyperdb item wrapper`_, then it's a value from the + hyperdb +2. if access through a `hyperdb class wrapper`_, then it's a value from the + CGI form + + +The property wrapper has some useful attributes: + +=============== ============================================================= +Attribute Description +=============== ============================================================= +_name the name of the property +_value the value of the property if any - this is the actual value + retrieved from the hyperdb for this property +=============== ============================================================= + +There are several methods available on these wrapper objects: + +=========== ================================================================= +Method Description +=========== ================================================================= +plain render a "plain" representation of the property +field render an appropriate form edit field for the property - for most + types this is a text entry box, but for Booleans it's a tri-state + yes/no/neither selection. +stext only on String properties - render the value of the + property as StructuredText (requires the StructureText module + to be installed separately) +multiline only on String properties - render a multiline form edit + field for the property +email only on String properties - render the value of the + property as an obscured email address +confirm only on Password properties - render a second form edit field for + the property, used for confirmation that the user typed the + password correctly. Generates a field with name "name:confirm". +reldate only on Date properties - render the interval between the + date and now +pretty only on Interval properties - render the interval in a + pretty format (eg. "yesterday") +menu only on Link and Multilink properties - render a form select + list for this property +reverse only on Multilink properties - produce a list of the linked + items in reverse order +=========== ================================================================= + +The request variable +~~~~~~~~~~~~~~~~~~~~ + +Note: this is implemented by the roundup.cgi.templating.HTMLRequest class. + +The request variable is packed with information about the current request. + +.. taken from roundup.cgi.templating.HTMLRequest docstring + +=========== ================================================================= +Variable Holds +=========== ================================================================= +form the CGI form as a cgi.FieldStorage +env the CGI environment variables +base the base URL for this tracker +user a HTMLUser instance for this user +classname the current classname (possibly None) +template the current template (suffix, also possibly None) +form the current CGI form variables in a FieldStorage +=========== ================================================================= + +**Index page specific variables (indexing arguments)** + +=========== ================================================================= +Variable Holds +=========== ================================================================= +columns dictionary of the columns to display in an index page +show a convenience access to columns - request/show/colname will + be true if the columns should be displayed, false otherwise +sort index sort column (direction, column name) +group index grouping property (direction, column name) +filter properties to filter the index on +filterspec values to filter the index on +search_text text to perform a full-text search on for an index +=========== ================================================================= + +There are several methods available on the request variable: + +=============== ============================================================= +Method Description +=============== ============================================================= +description render a description of the request - handle for the page + title +indexargs_form render the current index args as form elements +indexargs_url render the current index args as a URL +base_javascript render some javascript that is used by other components of + the templating +batch run the current index args through a filter and return a + list of items (see `hyperdb item wrapper`_, and + `batching`_) +=============== ============================================================= + +The form variable +::::::::::::::::: + +The form variable is a little special because it's actually a python +FieldStorage object. That means that you have two ways to access its +contents. For example, to look up the CGI form value for the variable +"name", use the path expression:: + + request/form/name/value + +or the python expression:: + + python:request.form['name'].value + +Note the "item" access used in the python case, and also note the explicit +"value" attribute we have to access. That's because the form variables are +stored as MiniFieldStorages. If there's more than one "name" value in +the form, then the above will break since ``request/form/name`` is actually a +*list* of MiniFieldStorages. So it's best to know beforehand what you're +dealing with. + + +The db variable +~~~~~~~~~~~~~~~ + +Note: this is implemented by the roundup.cgi.templating.HTMLDatabase class. + +Allows access to all hyperdb classes as attributes of this variable. If you +want access to the "user" class, for example, you would use:: + + db/user + python:db.user + +The access results in a `hyperdb class wrapper`_. + +The templates variable +~~~~~~~~~~~~~~~~~~~~~~ + +Note: this is implemented by the roundup.cgi.templating.Templates class. + +This variable doesn't have any useful methods defined. It supports being +used in expressions to access the templates, and subsequently the template +macros. You may access the templates using the following path expression:: + + templates/name + +or the python expression:: + + templates[name] + +where "name" is the name of the template you wish to access. The template you +get access to has one useful attribute, "macros". To access a specific macro +(called "macro_name"), use the path expression:: + + templates/name/macros/macro_name + +or the python expression:: + + templates[name].macros[macro_name] + + +The utils variable +~~~~~~~~~~~~~~~~~~ + +Note: this is implemented by the roundup.cgi.templating.TemplatingUtils class, +but it may be extended as described below. + +=============== ============================================================= +Method Description +=============== ============================================================= +Batch return a batch object using the supplied list +=============== ============================================================= + +You may add additional utility methods by writing them in your tracker +``interfaces.py`` module's ``TemplatingUtils`` class. See `adding a time log +to your issues`_ for an example. The TemplatingUtils class itself will have a +single attribute, ``client``, which may be used to access the ``client.db`` +when you need to perform arbitrary database queries. + +Batching +:::::::: + +Use Batch to turn a list of items, or item ids of a given class, into a series +of batches. Its usage is:: + + python:util.Batch(sequence, size, start, end=0, orphan=0, overlap=0) + +or, to get the current index batch:: + + request/batch + +The parameters are: + +========= ================================================================== +Parameter Usage +========= ================================================================== +sequence a list of HTMLItems +size how big to make the sequence. +start where to start (0-indexed) in the sequence. +end where to end (0-indexed) in the sequence. +orphan if the next batch would contain less items than this + value, then it is combined with this batch +overlap the number of items shared between adjacent batches +========= ================================================================== + +All of the parameters are assigned as attributes on the batch object. In +addition, it has several more attributes: + +=============== ============================================================ +Attribute Description +=============== ============================================================ +start indicates the start index of the batch. *Note: unlike the + argument, is a 1-based index (I know, lame)* +first indicates the start index of the batch *as a 0-based + index* +length the actual number of elements in the batch +sequence_length the length of the original, unbatched, sequence. +=============== ============================================================ + +And several methods: + +=============== ============================================================ +Method Description +=============== ============================================================ +previous returns a new Batch with the previous batch settings +next returns a new Batch with the next batch settings +propchanged detect if the named property changed on the current item + when compared to the last item +=============== ============================================================ + +An example of batching:: + + <table class="otherinfo"> + <tr><th colspan="4" class="header">Existing Keywords</th></tr> + <tr tal:define="keywords db/keyword/list" + tal:repeat="start python:range(0, len(keywords), 4)"> + <td tal:define="batch python:utils.Batch(keywords, 4, start)" + tal:repeat="keyword batch" tal:content="keyword/name">keyword here</td> + </tr> + </table> + +... which will produce a table with four columns containing the items of the +"keyword" class (well, their "name" anyway). + +Displaying Properties +--------------------- + +Properties appear in the user interface in three contexts: in indices, in +editors, and as search arguments. +For each type of property, there are several display possibilities. +For example, in an index view, a string property may just be +printed as a plain string, but in an editor view, that property may be +displayed in an editable field. + + +Index Views +----------- + +This is one of the class context views. It is also the default view for +classes. The template used is "*classname*.index". + +Index View Specifiers +~~~~~~~~~~~~~~~~~~~~~ + +An index view specifier (URL fragment) looks like this (whitespace has been +added for clarity):: + + /issue?status=unread,in-progress,resolved& + topic=security,ui& + :group=+priority& + :sort==activity& + :filters=status,topic& + :columns=title,status,fixer + +The index view is determined by two parts of the specifier: the layout part and +the filter part. The layout part consists of the query parameters that begin +with colons, and it determines the way that the properties of selected items +are displayed. The filter part consists of all the other query parameters, and +it determines the criteria by which items are selected for display. +The filter part is interactively manipulated with the form widgets displayed in +the filter section. The layout part is interactively manipulated by clicking on +the column headings in the table. + +The filter part selects the union of the sets of items with values matching any +specified Link properties and the intersection of the sets of items with values +matching any specified Multilink properties. + +The example specifies an index of "issue" items. Only items with a "status" of +either "unread" or "in-progres" or "resolved" are displayed, and only items +with "topic" values including both "security" and "ui" are displayed. The items +are grouped by priority, arranged in ascending order; and within groups, sorted +by activity, arranged in descending order. The filter section shows filters for +the "status" and "topic" properties, and the table includes columns for the +"title", "status", and "fixer" properties. + +Searching Views +--------------- + +Note: if you add a new column to the ``:columns`` form variable potentials + then you will need to add the column to the appropriate `index views`_ + template so it is actually displayed. + +This is one of the class context views. The template used is typically +"*classname*.search". The form on this page should have "search" as its +``:action`` variable. The "search" action: + +- sets up additional filtering, as well as performing indexed text searching +- sets the ``:filter`` variable correctly +- saves the query off if ``:query_name`` is set. + +The searching page should lay out any fields that you wish to allow the user +to search one. If your schema contains a large number of properties, you +should be wary of making all of those properties available for searching, as +this can cause confusion. If the additional properties are Strings, consider +having their value indexed, and then they will be searchable using the full +text indexed search. This is both faster, and more useful for the end user. + +The two special form values on search pages which are handled by the "search" +action are: + +:search_text + Text to perform a search of the text index with. Results from that search + will be used to limit the results of other filters (using an intersection + operation) +:query_name + If supplied, the search parameters (including :search_text) will be saved + off as a the query item and registered against the user's queries property. + Note that the *classic* template schema has this ability, but the *minimal* + template schema does not. + + +Item Views +---------- + +The basic view of a hyperdb item is provided by the "*classname*.item" +template. It generally has three sections; an "editor", a "spool" and a +"history" section. + + + +Editor Section +~~~~~~~~~~~~~~ + +The editor section is used to manipulate the item - it may be a +static display if the user doesn't have permission to edit the item. + +Here's an example of a basic editor template (this is the default "classic" +template issue item edit form - from the "issue.item" template):: + + <table class="form"> + <tr> + <th nowrap>Title</th> + <td colspan=3 tal:content="structure python:context.title.field(size=60)">title</td> + </tr> + + <tr> + <th nowrap>Priority</th> + <td tal:content="structure context/priority/menu">priority</td> + <th nowrap>Status</th> + <td tal:content="structure context/status/menu">status</td> + </tr> + + <tr> + <th nowrap>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')" /> + <span tal:condition="context/superseder"> + <br>View: <span tal:replace="structure python:context.superseder.link(showid=1)" /> + </span> + </td> + <th nowrap>Nosy List</th> + <td> + <span tal:replace="structure context/nosy/field" /> + <span tal:replace="structure python:db.user.classhelp('username,realname,address,phone')" /> + </td> + </tr> + + <tr> + <th nowrap>Assigned To</th> + <td tal:content="structure context/assignedto/menu"> + assignedto menu + </td> + <td> </td> + <td> </td> + </tr> + + <tr> + <th nowrap>Change Note</th> + <td colspan=3> + <textarea name=":note" wrap="hard" rows="5" cols="60"></textarea> + </td> + </tr> + + <tr> + <th nowrap>File</th> + <td colspan=3><input type="file" name=":file" size="40"></td> + </tr> + + <tr> + <td> </td> + <td colspan=3 tal:content="structure context/submit"> + submit button will go here + </td> + </tr> + </table> + + +When a change is submitted, the system automatically generates a message +describing the changed properties. As shown in the example, the editor +template can use the ":note" and ":file" fields, which are added to the +standard change note message generated by Roundup. + +Spool Section +~~~~~~~~~~~~~ + +The spool section lists related information like the messages and files of +an issue. + +TODO + + +History Section +~~~~~~~~~~~~~~~ + +The final section displayed is the history of the item - its database journal. +This is generally generated with the template:: + + <tal:block tal:replace="structure context/history" /> + +*To be done:* + +*The actual history entries of the item may be accessed for manual templating +through the "journal" method of the item*:: + + <tal:block tal:repeat="entry context/journal"> + a journal entry + </tal:block> + +*where each journal entry is an HTMLJournalEntry.* + +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. + +Adding action methods takes three steps; first you `define the new action +method`_, then you `register the action method`_ with the cgi interface so +it may be triggered by the ``:action`` form variable. Finally you actually +`use the new action`_ in your HTML form. + +See "`setting up a "wizard" (or "druid") for controlled adding of issues`_" +for an example. + +Define the new action method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The action methods have the following interface:: + + def myActionMethod(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 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 next +- raise Unauthorised, SendStaticFile, SendFile, NotFound or Redirect + exceptions + + +Register the action method +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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:: + + actions = client.Class.actions + ( + ('myaction', 'myActionMethod'), + ) + +This maps the action name "myaction" to the action method we defined. + + +Use the new action +~~~~~~~~~~~~~~~~~~ + +In your HTML form, add a hidden form element like so:: + + <input type="hidden" name=":action" value="myaction"> + +where "myaction" is the name you registered in the previous step. + + +Examples +======== + +.. contents:: + :local: + :depth: 1 + +Adding a new field to the classic schema +---------------------------------------- + +This example shows how to add a new constrained property (ie. 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 issue: a category. + +This would let sysads quickly list all todos in their particular +area of interest without having to do complex queries, and without +relying on the spelling capabilities of other sysads (a losing +proposition at best). + +Adding a field to the database +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This is the easiest part of the change. The category would just be a plain +string, nothing fancy. To change what is in the database you need to add +some lines to the ``open()`` function in ``dbinit.py`` under the comment:: + + # add any additional database schema configuration here + +add:: + + category = Class(db, "category", name=String()) + category.setkey("name") + +Here we are setting up a chunk of the database which we are calling +"category". It contains a string, which we are refering to as "name" for +lack of a more imaginative title. Then we are setting the key of this chunk +of the database to be that "name". This is equivalent to an index for +database types. This also means that there can only be one category with a +given name. + +Adding the above lines allows us to create categories, but they're not tied +to the issues that we are going to be creating. It's just a list of categories +off on its own, which isn't much use. We need to link it in with the issues. +To do that, find the lines in the ``open()`` function in ``dbinit.py`` which +set up the "issue" class, and then add a link to the category:: + + issue = IssueClass(db, "issue", ... , category=Multilink("category"), ... ) + +The Multilink() means that each issue can have many categories. If you were +adding something with a more one to one relationship use Link() instead. + +That is all you need to do to change the schema. The rest of the effort is +fiddling around so you can actually use the new category. + +Populating the new category class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you haven't initialised the database with the roundup-admin "initialise" +command, then you can add the following to the tracker ``dbinit.py`` in the +``init()`` function under the comment:: + + # add any additional database create steps here - but only if you + # haven't initialised the database with the admin "initialise" command + +add:: + + category = db.getclass('category') + category.create(name="scipy", order="1") + category.create(name="chaco", order="2") + category.create(name="weave", order="3") + +If the database is initalised, the you need to use the roundup-admin tool:: + + % roundup-admin -i <tracker home> + Roundup <version> ready for input. + Type "help" for help. + roundup> create category name=scipy order=1 + 1 + roundup> create category name=chaco order=1 + 2 + roundup> create category name=weave order=1 + 3 + roundup> exit... + There are unsaved changes. Commit them (y/N)? y + + +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 as +required, and obviously everyone needs to be able to view the categories of +issues for it to be useful. + +We therefore need to change the security of the category objects. This is +also done in the ``open()`` function of ``dbinit.py``. + +There are currently two loops which set up permissions and then assign them +to various roles. Simply add the new "category" to both lists:: + + # new permissions for this schema + for cl in 'issue', 'file', 'msg', 'user', 'category': + 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) + + # Assign the access and edit permissions for issue, file and message + # to regular users now + for cl in 'issue', 'file', 'msg', 'category': + p = db.security.getPermission('View', cl) + db.security.addPermissionToRole('User', p) + p = db.security.getPermission('Edit', cl) + db.security.addPermissionToRole('User', p) + +So you are in effect doing the following:: + + db.security.addPermission(name="Edit", klass='category', + description="User is allowed to edit "+'category') + db.security.addPermission(name="View", klass='category', + description="User is allowed to access "+'category') + +which is creating two permission types; that of editing and viewing +"category" objects respectively. Then the following lines assign those new +permissions to the "User" role, so that normal users can view and edit +"category" objects:: + + p = db.security.getPermission('View', 'category') + db.security.addPermissionToRole('User', p) + + p = db.security.getPermission('Edit', 'category') + db.security.addPermissionToRole('User', p) + +This is all the work that needs to be done for the database. It will store +categories, and let users view and edit them. Now on to the interface +stuff. + +Changing the web left hand frame +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We need to give the users the ability to create new categories, and the +place to put the link to this functionality is in the left hand function +bar, under the "Issues" area. The file that defines how this area looks is +``html/page``, which is what we are going to be editing next. + +If you look at this file you can see that it contains a lot of "classblock" +sections which are chunks of HTML that will be included or excluded in the +output depending on whether the condition in the classblock is met. Under +the end of the classblock for issue is where we are going to add the +category code:: + + <p class="classblock" + tal:condition="python:request.user.hasPermission('View', 'category')"> + <b>Categories</b><br> + <a tal:condition="python:request.user.hasPermission('Edit', 'category')" + href="category?:template=item">New Category<br></a> + </p> + +The first two lines is the classblock definition, which sets up a condition +that only users who have "View" permission to the "category" object will +have this section included in their output. Next comes a plain "Categories" +header in bold. Everyone who can view categories will get that. + +Next comes the link to the editing area of categories. This link will only +appear if the condition is matched: that condition being that the user has +"Edit" permissions for the "category" objects. If they do have permission +then they will get a link to another page which will let the user add new +categories. + +Note that if you have permission to view but not edit categories then all +you will see is a "Categories" header with nothing underneath it. This is +obviously not very good interface design, but will do for now. I just claim +that it is so I can add more links in this section later on. However to fix +the problem you could change the condition in the classblock statement, so +that only users with "Edit" permission would see the "Categories" stuff. + +Setting up a page to edit categories +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We defined code in the previous section which let users with the +appropriate permissions see a link to a page which would let them edit +conditions. Now we have to write that page. + +The link was for the item template for the category object. This translates +into the system looking for a file called ``category.item`` in the ``html`` +tracker directory. This is the file that we are going to write now. + +First we add an info tag in a comment which doesn't affect the outcome +of the code at all but is useful for debugging. If you load a page in a +browser and look at the page source, you can see which sections come +from which files by looking for these comments:: + + <!-- category.item --> + +Next we need to add in the METAL macro stuff so we get the normal page +trappings:: + + <tal:block metal:use-macro="templates/page/macros/icing"> + <title metal:fill-slot="head_title">Category editing</title> + <td class="page-header-top" metal:fill-slot="body_title"> + <h2>Category editing</h2> + </td> + <td class="content" metal:fill-slot="content"> + +Next we need to setup up a standard HTML form, which is the whole +purpose of this file. We link to some handy javascript which sends the form +through only once. This is to stop users hitting the send button +multiple times when they are impatient and thus having the form sent +multiple times:: + + <form method="POST" onSubmit="return submit_once()" + enctype="multipart/form-data"> + +Next we define some code which sets up the minimum list of fields that we +require the user to enter. There will be only one field, that of "name", so +they user better put something in it otherwise the whole form is pointless:: + + <input type="hidden" name=":required" value="name"> + +To get everything to line up properly we will put everything in a table, +and put a nice big header on it so the user has an idea what is happening:: + + <table class="form"> + <tr><th class="header" colspan=2>Category</th></tr> + +Next we need the actual field that the user is going to enter the new +category. The "context.name.field(size=60)" bit tells roundup to generate a +normal HTML field of size 60, and the contents of that field will be the +"name" variable of the current context (which is "category"). The upshot of +this is that when the user types something in to the form, a new category +will be created with that name:: + + <tr> + <th nowrap>Name</th> + <td tal:content="structure python:context.name.field(size=60)">name</td> + </tr> + +Then a submit button so that the user can submit the new category:: + + <tr> + <td> </td> + <td colspan=3 tal:content="structure context/submit"> + submit button will go here + </td> + </tr> + +Finally we finish off the tags we used at the start to do the METAL stuff:: + + </td> + </tal:block> + +So putting it all together, and closing the table and form we get:: + + <!-- category.item --> + <tal:block metal:use-macro="templates/page/macros/icing"> + <title metal:fill-slot="head_title">Category editing</title> + <td class="page-header-top" metal:fill-slot="body_title"> + <h2>Category editing</h2> + </td> + <td class="content" metal:fill-slot="content"> + <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> + <td tal:content="structure python:context.name.field(size=60)">name</td> + </tr> + + <tr> + <td> </td> + <td colspan=3 tal:content="structure context/submit"> + submit button will go here + </td> + </tr> + </table> + </form> + </td> + </tal:block> + +This is quite a lot to just ask the user one simple question, but +there is a lot of setup for basically one line (the form line) to do +its work. To add another field to "category" would involve one more line +(well maybe a few extra to get the formatting correct). + +Adding the category to the issue +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We now have the ability to create issues to our hearts content, but +that is pointless unless we can assign categories to issues. Just like +the ``html/category.item`` file was used to define how to add a new +category, the ``html/issue.item`` is used to define how a new issue is +created. + +Just like ``category.issue`` this file defines a form which has a table to lay +things out. It doesn't matter where in the table we add new stuff, +it is entirely up to your sense of aesthetics:: + + <th nowrap>Category</th> + <td><span tal:replace="structure context/category/field" /> + <span tal:replace="structure db/category/classhelp" /> + </td> + +First we define a nice header so that the user knows what the next section +is, then the middle line does what we are most interested in. This +``context/category/field`` gets replaced with a field which contains the +category in the current context (the current context being the new issue). + +The classhelp lines generate a link (labelled "list") to a popup window +which contains the list of currently known categories. + +Searching on categories +~~~~~~~~~~~~~~~~~~~~~~~ + +We can add categories, and create issues with categories. The next obvious +thing that we would like to be would be to search issues based on their +category, so that any one working on the web server could look at all +issues in the category "Web" for example. + +If you look in the html/page file and look for the "Search Issues" you will +see that it looks something like ``<a href="issue?:template=search">Search +Issues</a>`` which shows us that when you click on "Search Issues" it will +be looking for a ``issue.search`` file to display. So that is indeed the file +that we are going to change. + +If you look at this file it should be starting to seem familiar. It is a +simple HTML form using a table to define structure. You can add the new +category search code anywhere you like within that form:: + + <tr> + <th>Category:</th> + <td> + <select name="category"> + <option value="">don't care</option> + <option value="">------------</option> + <option tal:repeat="s db/category/list" tal:attributes="value s/name" + tal:content="s/name">category to filter on</option> + </select> + </td> + <td><input type="checkbox" name=":columns" value="category" checked></td> + <td><input type="radio" name=":sort" value="category"></td> + <td><input type="radio" name=":group" value="category"></td> + </tr> + +Most of this is straightforward to anyone who knows HTML. It is just +setting up a select list followed by a checkbox and a couple of radio +buttons. + +The ``tal:repeat`` part repeats the tag for every item in the "category" +table and setting "s" to be each category in turn. + +The ``tal:attributes`` part is setting up the ``value=`` part of the option tag +to be the name part of "s" which is the current category in the loop. + +The ``tal:content`` part is setting the contents of the option tag to be the +name part of "s" again. For objects more complex than category, obviously +you would put an id in the value, and the descriptive part in the content; +but for category they are the same. + +Adding category to the default view +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We can now add categories, add issues with categories, and search issues +based on categories. This is everything that we need to do, however there +is some more icing that we would like. I think the category of an issue is +important enough that it should be displayed by default when listing all +the issues. + +Unfortunately, this is a bit less obvious than the previous steps. The code +defining how the issues look is in ``html/issue.index``. This is a large table +with a form down the bottom for redisplaying and so forth. + +Firstly we need to add an appropriate header to the start of the table:: + + <th tal:condition="request/show/category">Category</th> + +The condition part of this statement is so that if the user has selected +not to see the Category column then they won't. + +The rest of the table is a loop which will go through every issue that +matches the display criteria. The loop variable is "i" - which means that +every issue gets assigned to "i" in turn. + +The new part of code to display the category will look like this:: + + <td tal:condition="request/show/category" tal:content="i/category"></td> + +The condition is the same as above: only display the condition when the +user hasn't asked for it to be hidden. The next part is to set the content +of the cell to be the category part of "i" - the current issue. + +Finally we have to edit ``html/page`` again. This time to tell it that when the +user clicks on "Unnasigned Issues" or "All Issues" that the category should +be displayed. If you scroll down the page file, you can see the links with +lots of options. The option that we are interested in is the ``:columns=`` one +which tells roundup which fields of the issue to display. Simply add +"category" to that list and it all should work. + + +Adding in state transition control +---------------------------------- + +Sometimes tracker admins want to control the states that users may move issues +to. + +1. 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. + +2. add an auditor module ``checktransition.py`` in your tracker's + ``detectors`` directory:: + + 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) + +3. in the ``issue.item`` template, change the status editing bit from:: + + <th nowrap>Status</th> + <td tal:content="structure context/status/menu">status</td> + + to:: + + <th nowrap>Status</th> + <td> + <select tal:condition="context/id" name="status"> + <tal:block tal:define="ok context/status/transitions" + tal:repeat="state db/status/list"> + <option tal:condition="python:state.id in ok" + tal:attributes="value state/id; + selected python:state.id == context.status.id" + tal:content="state/name"></option> + </tal:block> + </select> + <tal:block tal:condition="not:context/id" + tal:replace="structure context/status/menu" /> + </td> + + which displays only the allowed status to transition to. + + +Displaying only message summaries in the issue display +------------------------------------------------------ + +Alter the issue.item template section for messages to:: + + <table class="messages" tal:condition="context/messages"> + <tr><th colspan=5 class="header">Messages</th></tr> + <tr tal:repeat="msg context/messages"> + <td><a tal:attributes="href string:msg${msg/id}" + tal:content="string:msg${msg/id}"></a></td> + <td tal:content="msg/author">author</td> + <td nowrap tal:content="msg/date/pretty">date</td> + <td tal:content="msg/summary">summary</td> + <td> + <a tal:attributes="href string:?:remove:messages=${msg/id}&:action=edit">remove</a> + </td> + </tr> + </table> + +Restricting the list of users that are assignable to a task +----------------------------------------------------------- + +1. In your tracker's "dbinit.py", create a new Role, say "Developer":: + + db.security.addRole(name='Developer', description='A developer') + +2. Just after that, create a new Permission, say "Fixer", specific to "issue":: + + p = db.security.addPermission(name='Fixer', klass='issue', + description='User is allowed to be assigned to fix issues') + +3. Then assign the new Permission to your "Developer" Role:: + + db.security.addPermissionToRole('Developer', p) + +4. In the issue item edit page ("html/issue.item" in your tracker dir), 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 set up 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 the edit attempts to set the 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 nowrap>Operating System</th> + <td tal:content="structure context/os/field"></td> + </tr> + <tr> + <th nowrap>Web Browser</th> + <td tal:content="structure context/browser/field"></td> + </tr> + </tal:block> + + ... the above section will only be displayed if the category is one of 6, + 10, 13, 14, 15, 16 or 17. + +3. Determine what actions need to be taken between the pages - these are + usually to validate user choices and determine what page is next. Now + encode those actions in methods on the interfaces Client class and insert + hooks to those actions in the "actions" attribute on that class, like so:: + + actions = client.Client.actions + ( + ('page1_submit', 'page1SubmitAction'), + ) + + def page1SubmitAction(self): + ''' Verify that the user has selected a category, and then move on + to page 2. + ''' + category = self.form['category'].value + if category == '-1': + self.error_message.append('You must select a category of report') + return + # everything's ok, move on to the next page + self.template = 'add_page2' + +4. Use the usual "new" action as the :action on the final page, and you're + done (the standard context/submit method can do this for you). + + +Using an external password validation source +-------------------------------------------- + +We have a centrally-managed password changing system for our users. This +results in a UN*X passwd-style file that we use for verification of users. +Entries in the file consist of ``name:password`` where the password is +encrypted using the standard UN*X ``crypt()`` function (see the ``crypt`` +module in your Python distribution). An example entry would be:: + + admin:aamrgyQfDFSHw + +Each user of Roundup must still have their information stored in the Roundup +database - we just use the passwd file to check their password. To do this, we +add the following code to 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') + + # the passwords are stored in the "passwd.txt" file in the tracker + # home + file = os.path.join(self.db.config.TRACKER_HOME, 'passwd.txt') + + # see if we can find a match + for ent in [line.strip().split(':') for line in open(file).readlines()]: + if ent[0] == username: + return crypt.crypt(password, ent[1][:2]) == ent[1] + + # user doesn't exist in the file + return 0 + +What this does is look through the file, line by line, looking for a name that +matches. + +We also remove the redundant password fields from the ``user.item`` template. + + +Adding a "vacation" flag to users for stopping nosy messages +------------------------------------------------------------ + +When users go on vacation and set up vacation email bouncing, you'll start to +see a lot of messages come back through Roundup "Fred is on vacation". Not +very useful, and relatively easy to stop. + +1. add a "vacation" flag to your users:: + + user = Class(db, "user", + username=String(), password=Password(), + address=String(), realname=String(), + phone=String(), organisation=String(), + alternate_addresses=String(), + roles=String(), queries=Multilink("query"), + vacation=Boolean()) + +2. 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, just below the change note box:: + + <tr> + <th nowrap>Time Log</th> + <td colspan=3><input name=":timelog"> + (enter as "3y 1m 4d 2:40:02" or parts thereof) + </td> + </tr> + + Note that we've made up a new form variable, but since we place a colon ":" + in front of it, it won't clash with any existing property variables. The + names you *can't* use are ``:note``, ``:file``, ``:action``, ``:required`` + and ``:template``. These variables are described in the section + `performing actions in web requests`_. + +4. We also need to handle this new field in the CGI interface - the way to + do this is through implementing a new form action (see `Setting up a + "wizard" (or "druid") for controlled adding of issues`_ for another example + where we implemented a new CGI form action). + + In this case, we'll want our action to: + + 1. create a new "timelog" entry, + 2. fake that the issue's "times" property has been edited, and then + 3. call the normal CGI edit action handler. + + The code to do this is:: + + actions = client.Client.actions + ( + ('edit_with_timelog', 'timelogEditAction'), + ) + + def timelogEditAction(self): + ''' Handle the creation of a new time log entry if necessary. + + If we create a new entry, fake up a CGI form value for the altered + "times" property of the issue being edited. + + Punt to the regular edit action when we're done. + ''' + # if there's a timelog value specified, create an entry + if self.form.has_key(':timelog') and \ + self.form[':timelog'].value.strip(): + period = Interval(self.form[':timelog'].value) + # create it + newid = self.db.timelog.create(period=period) + + # if we're editing an existing item, get the old timelog value + if self.nodeid: + l = self.db.issue.get(self.nodeid, 'times') + l.append(newid) + else: + l = [newid] + + # now make the fake CGI form values + for entry in l: + self.form.list.append(MiniFieldStorage('times', entry)) + + # punt to the normal edit action + return self.editItemAction() + + you add this code to your Client class in your tracker's ``interfaces.py`` + file. + +5. You'll also need to modify your ``issue.item`` form submit action so it + calls the time logging action we just created:: + + <tr> + <td> </td> + <td colspan=3> + <tal:block tal:condition="context/id"> + <input type="hidden" name=":action" value="edit_with_timelog"> + <input type="submit" name="submit" value="Submit Changes"> + </tal:block> + <tal:block tal:condition="not:context/id"> + <input type="hidden" name=":action" value="new"> + <input type="submit" name="submit" value="Submit New Issue"> + </tal:block> + </td> + </tr> + + Note that the "context/submit" command usually handles all that for you - + isn't it handy? The important change is setting the action to + "edit_with_timelog" for edit operations (where the item exists) + +6. We want to display a total of the time log times that have been accumulated + for an issue. To do this, we'll need to actually write some Python code, + since it's beyond the scope of PageTemplates to perform such calculations. + We do this by adding a method to the TemplatingUtils class in our tracker + ``interfaces.py`` module:: + + + class TemplatingUtils: + ''' Methods implemented on this class will be available to HTML + templates through the 'utils' variable. + ''' + def totalTimeSpent(self, times): + ''' Call me with a list of timelog items (which have an Interval + "period" property) + ''' + total = Interval('') + for time in times: + total += time.period._value + return total + + As indicated in the docstrings, we will be able to access the + ``totalTimeSpent`` method via the ``utils`` variable in our templates. See + +7. Display the time log for an issue:: + + <table class="otherinfo" tal:condition="context/times"> + <tr><th colspan="3" class="header">Time Log + <tal:block tal:replace="python:utils.totalTimeSpent(context.times)" /> + </th></tr> + <tr><th>Date</th><th>Period</th><th>Logged By</th></tr> + <tr tal:repeat="time context/times"> + <td tal:content="time/creation"></td> + <td tal:content="time/period"></td> + <td tal:content="time/creator"></td> + </tr> + </table> + + I put this just above the Messages log in my issue display. Note our use + of the ``totalTimeSpent`` method which will total up the times for the + issue and return a new Interval. That will be automatically displayed in + the template as text like "+ 1y 2:40" (1 year, 2 hours and 40 minutes). + +8. If you're using a persistent web server - roundup-server or mod_python for + example - then you'll need to restart that to pick up the code changes. + When that's done, you'll be able to use the new time logging interface. + +Using a UN*X passwd file as the user database +--------------------------------------------- + +On some systems the primary store of users is the UN*X passwd file. It holds +information on users such as their username, real name, password and primary +user group. + +Roundup can use this store as its primary source of user information, but it +needs additional information too - email address(es), roundup Roles, vacation +flags, roundup hyperdb item ids, etc. Also, "retired" users must still exist +in the user database, unlike some passwd files in which the users are removed +when they no longer have access to a system. + +To make use of the passwd file, we therefore synchronise between the two user +stores. We also use the passwd file to validate the user logins, as described +in the previous example, `using an external password validation source`_. We +keep the users lists in sync using a fairly simple script that runs once a +day, or several times an hour if more immediate access is needed. In short, it: + +1. parses the passwd file, finding usernames, passwords and real names, +2. compares that list to the current roundup user list: + + a. entries no longer in the passwd file are *retired* + b. entries with mismatching real names are *updated* + c. entries only exist in the passwd file are *created* + +3. send an email to administrators to let them know what's been done. + +The retiring and updating are simple operations, requiring only a call to +``retire()`` or ``set()``. The creation operation requires more information +though - the user's email address and their roundup Roles. We're going to +assume that the user's email address is the same as their login name, so we +just append the domain name to that. The Roles are determined using the +passwd group identifier - mapping their UN*X group to an appropriate set of +Roles. + +The script to perform all this, broken up into its main components, is as +follows. Firstly, we import the necessary modules and open the tracker we're +to work on:: + + import sys, os, smtplib + from roundup import instance, date + + # open the tracker + tracker_home = sys.argv[1] + tracker = instance.open(tracker_home) + +Next we read in the *passwd* file from the tracker home:: + + # read in the users + file = os.path.join(tracker_home, 'users.passwd') + users = [x.strip().split(':') for x in open(file).readlines()] + +Handle special users (those to ignore in the file, and those who don't appear +in the file):: + + # users to not keep ever, pre-load with the users I know aren't + # "real" users + ignore = ['ekmmon', 'bfast', 'csrmail'] + + # users to keep - pre-load with the roundup-specific users + keep = ['comment_pool', 'network_pool', 'admin', 'dev-team', 'cs_pool', + 'anonymous', 'system_pool', 'automated'] + +Now we map the UN*X group numbers to the Roles that users should have:: + + roles = { + '501': 'User,Tech', # tech + '502': 'User', # finance + '503': 'User,CSR', # customer service reps + '504': 'User', # sales + '505': 'User', # marketing + } + +Now we do all the work. Note that the body of the script (where we have the +tracker database open) is wrapped in a ``try`` / ``finally`` clause, so that +we always close the database cleanly when we're finished. So, we now do all +the work:: + + # open the database + db = tracker.open('admin') + try: + # store away messages to send to the tracker admins + msg = [] + + # loop over the users list read in from the passwd file + for user,passw,uid,gid,real,home,shell in users: + if user in ignore: + # this user shouldn't appear in our tracker + continue + keep.append(user) + try: + # see if the user exists in the tracker + uid = db.user.lookup(user) + + # yes, they do - now check the real name for correctness + if real != db.user.get(uid, 'realname'): + db.user.set(uid, realname=real) + msg.append('FIX %s - %s'%(user, real)) + except KeyError: + # nope, the user doesn't exist + db.user.create(username=user, realname=real, + address='%s@ekit-inc.com'%user, roles=roles[gid]) + msg.append('ADD %s - %s (%s)'%(user, real, roles[gid])) + + # now check that all the users in the tracker are also in our "keep" + # list - retire those who aren't + for uid in db.user.list(): + user = db.user.get(uid, 'username') + if user not in keep: + db.user.retire(uid) + msg.append('RET %s'%user) + + # if we did work, then send email to the tracker admins + if msg: + # create the email + msg = '''Subject: %s user database maintenance + + %s + '''%(db.config.TRACKER_NAME, '\n'.join(msg)) + + # send the email + smtp = smtplib.SMTP(db.config.MAILHOST) + addr = db.config.ADMIN_EMAIL + smtp.sendmail(addr, addr, msg) + + # now we're done - commit the changes + db.commit() + finally: + # always close the database cleanly + db.close() + +And that's it! + + +Enabling display of either message summaries or the entire messages +------------------------------------------------------------------- + +This is pretty simple - all we need to do is copy the code from the example +`displaying only message summaries in the issue display`_ into our template +alongside the summary display, and then introduce a switch that shows either +one or the other. We'll use a new form variable, ``:whole_messages`` to +achieve this:: + + <table class="messages" tal:condition="context/messages"> + <tal:block tal:condition="not:request/form/:whole_messages/value | python:0"> + <tr><th colspan=3 class="header">Messages</th> + <th colspan=2 class="header"> + <a href="?:whole_messages=yes">show entire messages</a> + </th> + </tr> + <tr tal:repeat="msg context/messages"> + <td><a tal:attributes="href string:msg${msg/id}" + tal:content="string:msg${msg/id}"></a></td> + <td tal:content="msg/author">author</td> + <td nowrap tal:content="msg/date/pretty">date</td> + <td tal:content="msg/summary">summary</td> + <td> + <a tal:attributes="href string:?:remove:messages=${msg/id}&:action=edit">remove</a> + </td> + </tr> + </tal:block> + + <tal:block tal:condition="request/form/:whole_messages/value | python:0"> + <tr><th colspan=2 class="header">Messages</th> + <th class="header"><a href="?:whole_messages=">show only summaries</a></th> + </tr> + <tal:block tal:repeat="msg context/messages"> + <tr> + <th tal:content="msg/author">author</th> + <th nowrap tal:content="msg/date/pretty">date</th> + <th style="text-align: right"> + (<a tal:attributes="href string:?:remove:messages=${msg/id}&:action=edit">remove</a>) + </th> + </tr> + <tr><td colspan=3 tal:content="msg/content"></td></tr> + </tal:block> + </tal:block> + </table> + + +------------------- + +Back to `Table of Contents`_ + +.. _`Table of Contents`: index.html +.. _`design documentation`: design.html +
