diff --git a/doc/customizing.txt b/doc/customizing.txt
index ac9235eae789cddd80d6eb9290efddf2456658b1..0136601910ec8a56f34d249378bd9c36dd0a9db6 100644 (file)
--- a/doc/customizing.txt
+++ b/doc/customizing.txt
Customising Roundup
===================
-:Version: $Revision: 1.7 $
+:Version: $Revision: 1.105 $
+
+.. 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
+===============
-Instances have the following structure:
-
-+-------------------+--------------------------------------------------------+
-|instance_config.py |Holds the basic instance_configuration |
-+-------------------+--------------------------------------------------------+
-|dbinit.py |Holds the instance_schema |
-+-------------------+--------------------------------------------------------+
-|interfaces.py |Defines the Web and E-Mail interfaces for the instance |
-+-------------------+--------------------------------------------------------+
-|select_db.py |Selects the database back-end for the instance |
-+-------------------+--------------------------------------------------------+
-|db/ |Holds the instance's database |
-+-------------------+--------------------------------------------------------+
-|db/files/ |Holds the instance's upload files and messages |
-+-------------------+--------------------------------------------------------+
-|detectors/ |Auditors and reactors for this instance |
-+-------------------+--------------------------------------------------------+
-|html/ |Web interface templates, images and style sheets |
-+-------------------+--------------------------------------------------------+
-
-Instance Configuration
-----------------------
+Before you get too far, it's probably worth having a quick read of the Roundup
+`design documentation`_.
-The instance_config.py located in your instance home contains the basic
-configuration for the web and e-mail components of roundup's interfaces. This
-file is a Python module. The default instance_config.py is given below - as you
-can see, the MAIL_DOMAIN must be edited before any interaction with the
-instance is attempted.::
+Customisation of Roundup can take one of six forms:
- MAIL_DOMAIN=MAILHOST=HTTP_HOST=None
- HTTP_PORT=0
+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`_
- # roundup home is this package's directory
- INSTANCE_HOME=os.path.split(__file__)[0]
+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.
- # The SMTP mail host that roundup will use to send mail
- if not MAILHOST:
- MAILHOST = 'localhost'
- # The domain name used for email addresses.
- if not MAIL_DOMAIN:
- MAIL_DOMAIN = 'fill.me.in.'
+Trackers in a Nutshell
+======================
- # the next two are only used for the standalone HTTP server.
- if not HTTP_HOST:
- HTTP_HOST = ''
- if not HTTP_PORT:
- HTTP_PORT = 9080
+Trackers have the following structure:
- # This is the directory that the database is going to be stored in
- DATABASE = os.path.join(INSTANCE_HOME, 'db')
+=================== ========================================================
+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
+=================== ========================================================
- # This is the directory that the HTML templates reside in
- TEMPLATES = os.path.join(INSTANCE_HOME, 'html')
+Tracker Configuration
+=====================
- # The email address that mail to roundup should go to
- ISSUE_TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN
+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::
- # The web address that the instance is viewable at
- ISSUE_TRACKER_WEB = 'http://some.useful.url/'
+ 'this is a string'
+ "this is also a string - use it when the value has 'single quotes'"
+ this is not a string - it's not quoted
- # The email address that roundup will complain to if it runs into trouble
- ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
+Python strings may use formatting that's almost identical to C string
+formatting. The ``%`` operator is used to perform the formatting, like
+so::
- # Somewhere for roundup to log stuff internally sent to stdout or stderr
- LOG = os.path.join(INSTANCE_HOME, 'roundup.log')
+ 'roundup-admin@%s'%MAIL_DOMAIN
- # Where to place the web filtering HTML on the index page
- FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom'
+this will create a string ``'roundup-admin@tracker.domain.example'`` if
+MAIL_DOMAIN is set to ``'tracker.domain.example'``.
- # Deny or allow anonymous access to the web interface
- ANONYMOUS_ACCESS = 'deny' # either 'deny' or 'allow'
+You'll also note some values are set to::
- # Deny or allow anonymous users to register through the web interface
- ANONYMOUS_REGISTER = 'deny' # either 'deny' or 'allow'
+ os.path.join(TRACKER_HOME, 'db')
- # Deny or allow anonymous users to register through the mail interface
- ANONYMOUS_REGISTER_MAIL = 'deny' # either 'deny' or 'allow'
+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.
- # Send nosy messages to the author of the message
- MESSAGES_TO_AUTHOR = 'no' # either 'yes' or 'no'
+The configuration variables available are:
- # 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'
+**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.
- # 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'
+**MAILHOST** - ``'localhost'``
+ The SMTP mail host that roundup will use to send e-mail.
- # Where to place the email signature
- EMAIL_SIGNATURE_POSITION = 'bottom' # one of 'top', 'bottom', 'none'
+**MAILUSER** - ``()``
+ If your SMTP mail host requires a username and password for access, then
+ specify them here. eg. ``MAILUSER = ('username', 'password')``
- # Keep email citations
- EMAIL_KEEP_QUOTED_TEXT = 'no' # either 'yes' or 'no'
+**MAILHOST_TLS** - ``'no'``
+ If your SMTP mail host provides or requires TLS (Transport Layer
+ Security) then set ``MAILHOST_TLS = 'yes'``
- # Preserve the email body as is
- EMAIL_LEAVE_BODY_UNCHANGED = 'no' # either 'yes' or 'no'
+**MAILHOST_TLS_KEYFILE** - ``''``
+ If you're using TLS, you may also set MAILHOST_TLS_KEYFILE to the name of
+ a PEM formatted file that contains your private key.
- # 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)
+**MAILHOST_TLS_CERTFILE** - ``''``
+ If you're using TLS and have specified a MAILHOST_TLS_KEYFILE, you may
+ also set MAILHOST_TLS_CERTFILE to the name of a PEM formatted certificate
+ chain file.
+**MAIL_DOMAIN** - ``'tracker.domain.example'``
+ The domain name used for email addresses.
-Instance Schema
----------------
+**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://tracker.example/cgi-bin/roundup.cgi/bugs/'``
+ The web address that the tracker is viewable at. This will be included in
+ information sent to users of the tracker. The URL **must** include the
+ cgi-bin part or anything else that is required to get to the home page of
+ the tracker. You **must** include a trailing '/' in the URL.
+
+**ADMIN_EMAIL** - ``'roundup-admin@%s'%MAIL_DOMAIN``
+ The email address that roundup will complain to if it runs into trouble.
+
+**EMAIL_FROM_TAG** - ``''``
+ Additional text to include in the "name" part of the ``From:`` address used
+ in nosy messages. If the sending user is "Foo Bar", the ``From:`` line is
+ usually::
+
+ "Foo Bar" <issue_tracker@tracker.example>
+
+ The EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so::
+
+ "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
+
+**MESSAGES_TO_AUTHOR** - ``'new'``, ``'yes'`` or``'no'``
+ Send nosy messages to the author of the message?
+ If 'new' is used, then the author will only be sent the message when the
+ message creates a new issue. If 'yes' then the author will always be sent
+ a copy of the message they wrote.
+
+**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. This will be
+ # included in information sent to users of the tracker. The URL MUST
+ # include the cgi-bin part or anything else that is required to get
+ # to the home page of the tracker. You MUST include a trailing '/'
+ # in the URL.
+ TRACKER_WEB = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/'
+
+ # The email address that roundup will complain to if it runs into
+ # trouble
+ ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
+
+ # Additional text to include in the "name" part of the From: address
+ # used in nosy messages. If the sending user is "Foo Bar", the From:
+ # line is usually: "Foo Bar" <issue_tracker@tracker.example>
+ # the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so:
+ # "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
+ EMAIL_FROM_TAG = ""
+
+ # Send nosy messages to the author of the message
+ MESSAGES_TO_AUTHOR = 'no' # either 'yes' or 'no'
+
+ # 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)
+
+ #
+ # SECURITY DEFINITIONS
+ #
+ # define the Roles that a user gets when they register with the
+ # tracker these are a comma-separated string of role names (e.g.
+ # 'Admin,User')
+ NEW_WEB_USER_ROLES = 'User'
+ NEW_EMAIL_USER_ROLES = 'User'
+
+Tracker Schema
+==============
Note: if you modify the schema, you'll most likely need to edit the
- `web interface`_ HTML template files to reflect your changes.
+ `web interface`_ HTML template files and `detectors`_ to reflect
+ your changes.
-An instance schema defines what data is stored in the instance's database. The
-two schemas shipped with Roundup turn it into a typical software bug tracker
-(the extended schema allowing for support issues as well as bugs). Schemas are
-defined using Python code. The "classic" schema looks like this::
+A tracker schema defines what data is stored in the tracker's database.
+Schemas are defined using Python code in the ``dbinit.py`` module of your
+tracker. The "classic" schema looks like this (see below for the meaning
+of ``'setkey'``)::
pri = Class(db, "priority", name=String(), order=String())
pri.setkey("name")
- pri.create(name="critical", order="1")
- pri.create(name="urgent", order="2")
- pri.create(name="bug", order="3")
- pri.create(name="feature", order="4")
- pri.create(name="wish", order="5")
stat = Class(db, "status", name=String(), order=String())
stat.setkey("name")
- stat.create(name="unread", order="1")
- stat.create(name="deferred", order="2")
- stat.create(name="chatting", order="3")
- stat.create(name="need-eg", order="4")
- stat.create(name="in-progress", order="5")
- stat.create(name="testing", order="6")
- stat.create(name="done-cbb", order="7")
- stat.create(name="resolved", order="8")
keyword = Class(db, "keyword", name=String())
keyword.setkey("name")
- user = Class(db, "user", username=String(), password=String(),
- address=String(), realname=String(), phone=String(),
- organisation=String())
+ user = Class(db, "user", username=String(), organisation=String(),
+ password=String(), address=String(), realname=String(),
+ phone=String())
user.setkey("username")
- user.create(username="admin", password=adminpw,
- address=instance_config.ADMIN_EMAIL)
- msg = FileClass(db, "msg", author=Link("user"), recipients=Multilink
- ("user"), date=Date(), summary=String(), files=Multilink("file"))
+ 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", assignedto=Link("user"),
- topic=Multilink("keyword"), priority=Link("priority"), status=Link
- ("status"))
+ issue = IssueClass(db, "issue", topic=Multilink("keyword"),
+ status=Link("status"), assignedto=Link("user"),
+ priority=Link("priority"))
issue.setkey('title')
+
+What you can't do to the schema
+-------------------------------
+
+You must never:
+
+**Remove the users class**
+ This class is the only *required* class in Roundup. Similarly, its
+ username, password and address properties must never be removed.
+
+**Change the type of a property**
+ Property types must *never* be changed - the database simply doesn't take
+ this kind of action into account. Note that you can't just remove a
+ property and re-add it as a new type either. If you wanted to make the
+ assignedto property a Multilink, you'd need to create a new property
+ assignedto_list and remove the old assignedto property.
+
+
+What you can do to the schema
+-----------------------------
+
+Your schema may be changed at any time before or after the tracker has been
+initialised (or used). You may:
+
+**Add new properties to classes, or add whole new classes**
+ This is painless and easy to do - there are generally no repurcussions
+ from adding new information to a tracker's schema.
+
+**Remove properties**
+ Removing properties is a little more tricky - you need to make sure that
+ the property is no longer used in the `web interface`_ *or* by the
+ detectors_.
+
+
+
Classes and Properties - creating a new information store
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+---------------------------------------------------------
-In the instance above, we've defined 7 classes of information:
+In the tracker above, we've defined 7 classes of information:
priority
Defines the possible levels of urgency for issues.
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.
+ 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.
+ Initially empty, will hold all e-mail messages sent to or
+ generated by roundup.
file
- Initially empty, will all files attached to issues.
+ Initially empty, will hold all files attached to issues.
issue
- Initially emtyp, this is where the issue information is stored.
+ 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.
+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 Nodes
-:::::::::::::::
-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 nodes.
-The actual data entered into the database, using class.create() are called
-nodes. They have a special immutable property called id. We sometimes refer to
-this as the nodeid.
+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 gives
+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.
- * A Link property refers to a single other node selected from a specified
- class. The class is part of the property; the value is an integer, the id
- of the chosen node.
- * A Multilink property refers to possibly many nodes in a specified class.
- The value is a list of integers.
+
+* 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.
-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 instance.
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 a node was created, and
-the value of the "activity" property is the date when any property on the node
-was last edited (equivalently, these are the dates on the first and last
-records in the node's journal). The "creator" property holds a link to the user
-that created the issue.
+
+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
+superseder link indicates an issue which has superseded 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 nodes 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::
+Select a String property of the class to be the key property. The key
+property must 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: for example, 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
+ roundup-admin set issue23 assignedto=2
or::
- roundup-admin set issue assignedto=richard
+ roundup-admin set issue23 assignedto=richard
+
+Note, the same thing can be done in the web and e-mail interfaces.
+
+If a class does not have an "order" property, the key is also used to
+sort instances of the class when it is rendered in the user interface.
+(If a class has no "order" property, sorting is by the labelproperty of
+the class. This is computed, in order of precedence, as the key, the
+"name", the "title", or the first property alphabetically.)
-Note, the same thing can be done in the web and e-mail interfaces.
create(information)
-:::::::::::::::::::
+~~~~~~~~~~~~~~~~~~~
-Create a node in the database. This is generally used to create nodes in the
-"definitional" classes like "priority" and "status".
+Create an item in the database. This is generally used to create items
+in the "definitional" classes like "priority" and "status".
-Detectors - adding behaviour to your tracker
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Examples of adding to your schema
+---------------------------------
+
+TODO
-Sample additional detectors that have been found useful will appear in the
-``detectors`` directory of the Roundup distribution:
-newissuecopy.py
+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
+initialised via the "roundup-admin initialise" 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 out what changes need to be made to the nosy list
+ (such as 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.
+**messagesummary.py**
+ Generates the ``summary`` property for new messages based on the message
+ content.
+**userauditor.py**
+ Verifies the content of some of the user fields (email addresses and
+ roles lists).
+
+If you don't want this default behaviour, you're completely free to change
+or remove these detectors.
+
+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. If you want
+to use one, copy it to the ``'detectors'`` of your tracker instance:
+
+**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!
+ 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 is built into 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 "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's ``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 their 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 do not not give 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 web access to users who register through email**
+ 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
--------------
-
-The web interface works behind the cgi-bin/roundup.cgi or roundup-server
-scripts. In both cases, the scripts determine which instance is being accessed
-(the first part of the URL path inside the scope of the CGI handler) and pass
-control on to the instance interfaces.Client class 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 instance.
-Most customisation of the web view can be done by modifying the templates in
-the instance html directory. These are divided into index, item and newitem
-views. The newitem view is optional - the item view will be used if the newitem
-view doesn't exist.
-
-Repurcussions of changing the instance schema
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-If you choose to change_the_instance_schema you will need to ensure the web
-interface knows about it:
-
- 1. Index, item and filter pages for the relevant classes may need to have
- properties added or removed,
- 2. The default page header relies on the existence of, and some values of
- the priority, status, assignedto and activity classes. If you change any
- of these (specifically if you remove any of the classes or their default
- values) you will need to implement your own pagehead() method in your
- instance's interfaces.py module.
+=============
+
+.. contents::
+ :local:
+ :depth: 1
+
+The web interface is provided by the ``roundup.cgi.client`` module and
+is used by ``roundup.cgi``, ``roundup-server`` and ``ZRoundup``
+(``ZRoundup`` is broken, until further notice). 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.
+
+Repercussions 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 (i.e. "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
+ variables`_ you may use.
+
+**new**
+ Add a new item to the database. You may use the same `special form
+ variables`_ 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 available in your
+tracker instance as ``interfaces.Client``. So if you need to define new
+actions, you may add them there (see `defining new web actions`_).
+
+Each action also has a corresponding ``*actionPermission*`` (where
+"action" is the name of the action) method which determines whether the
+action is permissible given the current user. The base permission checks
+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 the "Web Registration" Permission.
+**edit**
+ Determine whether the user has permission to edit this item. Base
+ behaviour is to check whether the user can edit this class. If we're
+ editing the "user" class, users are allowed to edit their own details -
+ unless they try to edit the "roles" property, which requires the
+ special Permission "Web Roles".
+**new**
+ Determine whether the user has permission to create (or 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 whether the user may edit this class.
+**search**
+ Determine whether the user has permission to search this class. Base
+ behaviour is to check whether the user may view this class.
+
+
+Special form variables
+----------------------
+
+Item properties and their values are edited with html FORM
+variables and their values. You can:
+
+- Change the value of some property of the current item.
+- Create a new item of any class, and edit the new item's
+ properties,
+- Attach newly created items to a multilink property of the
+ current item.
+- Remove items from a multilink property of the current item.
+- Specify that some properties are required for the edit
+ operation to be successful.
+
+In the following, <bracketed> values are variable, "@" may be
+either ":" or "@", and other text "required" is fixed.
+
+Most properties are specified as form variables:
+
+``<propname>``
+ property on the current context item
+
+``<designator>"@"<propname>``
+ property on the indicated item (for editing related information)
+
+Designators name a specific item of a class.
+
+``<classname><N>``
+ Name an existing item of class <classname>.
+
+``<classname>"-"<N>``
+ Name the <N>th new item of class <classname>. If the form
+ submission is successful, a new item of <classname> is
+ created. Within the submitted form, a particular
+ designator of this form always refers to the same new
+ item.
+
+Once we have determined the "propname", we look at it to see
+if it's special:
+
+``@required``
+ The associated form value is a comma-separated list of
+ property names that must be specified when the form is
+ submitted for the edit operation to succeed.
+
+ When the <designator> is missing, the properties are
+ for the current context item. When <designator> is
+ present, they are for the item specified by
+ <designator>.
+
+ The "@required" specifier must come before any of the
+ properties it refers to are assigned in the form.
+
+``@remove@<propname>=id(s)`` or ``@add@<propname>=id(s)``
+ The "@add@" and "@remove@" edit actions apply only to
+ Multilink properties. The form value must be a
+ comma-separate list of keys for the class specified by
+ the simple form variable. The listed items are added
+ to (respectively, removed from) the specified
+ property.
+
+``@link@<propname>=<designator>``
+ If the edit action is "@link@", the simple form
+ variable must specify a Link or Multilink property.
+ The form value is a comma-separated list of
+ designators. The item corresponding to each
+ designator is linked to the property given by simple
+ form variable.
+
+None of the above (ie. just a simple form value)
+ The value of the form variable is converted
+ appropriately, depending on the type of the property.
+
+ For a Link('klass') property, the form value is a
+ single key for 'klass', where the key field is
+ specified in dbinit.py.
+
+ For a Multilink('klass') property, the form value is a
+ comma-separated list of keys for 'klass', where the
+ key field is specified in dbinit.py.
+
+ Note that for simple-form-variables specifiying Link
+ and Multilink properties, the linked-to class must
+ have a key field.
+
+ For a String() property specifying a filename, the
+ file named by the form value is uploaded. This means we
+ try to set additional properties "filename" and "type" (if
+ they are valid for the class). Otherwise, the property
+ is set to the form value.
+
+ For Date(), Interval(), Boolean(), and Number()
+ properties, the form value is converted to the
+ appropriate
+
+Any of the form variables may be prefixed with a classname or
+designator.
+
+Two special form values are supported for backwards compatibility:
+
+@note
+ This is equivalent to::
+
+ @link@messages=msg-1
+ msg-1@content=value
+
+ except that in addition, the "author" and "date" properties of
+ "msg-1" are set to the userid of the submitter, and the current
+ time, respectively.
+
+@file
+ This is equivalent to::
+
+ @link@files=file-1
+ file-1@content=value
+
+ The String content value is handled as described above for file
+ uploads.
+
+If both the "@note" and "@file" form variables are
+specified, the action::
+
+ @link@msg-1@files=file-1
+
+is also performed.
+
+We also check that FileClass items have a "content" property with
+actual content, otherwise we remove them from all_props before
+returning.
+
+
+
+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. The *minimal* template includes:
+
+**page.html**
+ 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 also defines
+ the "head_title" and "body_title" slots to allow setting of the page
+ title.
+**home.html**
+ the default page displayed when no other page is indicated by the user
+**home.classlist.html**
+ a special version of the default page that lists the classes in the
+ tracker
+**classname.item.html**
+ displays an item of the *classname* class
+**classname.index.html**
+ displays a list of *classname* items
+**classname.search.html**
+ displays a search page for *classname* items
+**_generic.index.html**
+ used to display a list of items where there is no
+ ``*classname*.index`` available
+**_generic.help.html**
+ used to display a "class help" page where there is no
+ ``*classname*.help``
+**user.register.html**
+ a special page just for the user class, that renders the registration
+ page
+**style.css.html**
+ a static file that is served up as-is
+
+The *classic* template has a number of additional templates.
+
+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 the HTML tags.
+These attributes form the Template Attribute Language, or TAL. The basic
+TAL 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 this example, the variable "title" is defined as 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" />
+
+ 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, you may include tags such as <tal:block>, which are
+removed from output. Its content is kept, but the tag itself is not (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 attribute 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 - if it is a method, it
+ is called; if it is an object, it is stringified. Path expressions
+ may have an optional ``path:`` prefix, but 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.
+
+ You may use the python function ``path``, as in
+ ``path("item/status")``, to embed path expressions in Python
+ expressions.
+
+**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.
+
+Modifiers:
+
+**structure** - eg. ``structure python:msg.content.plain(hyperlink=1)``
+ The result of expressions are normally *escaped* to be safe for HTML
+ display (all "<", ">" and "&" are turned into special entities). The
+ ``structure`` expression modifier turns off this escaping - the
+ result of the expression is now assumed to be HTML, which is passed
+ to the web browser for rendering.
+
+**not:** - eg. ``not:python:1=1``
+ This simply inverts the logical true/false value of another
+ expression.
+
+
+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's
+ 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 of the
+``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. This method
+ may take two arguments:
+
+ escape
+ If true, escape the text so it is HTML safe (default: no). The
+ reason this defaults to off is that text is usually escaped
+ at a later stage by the TAL commands, unless the "structure"
+ option is used in the template. The following ``tal:content``
+ expressions are all equivalent::
+
+ "structure python:msg.content.plain(escape=1)"
+ "python:msg.content.plain()"
+ "msg/content/plain"
+ "msg/content"
+
+ Usually you'll only want to use the escape option in a
+ complex expression.
+
+ hyperlink
+ If true, turn URLs, email addresses and hyperdb item
+ designators in the text into hyperlinks (default: no). Note
+ that you'll need to use the "structure" TAL option if you
+ want to use this ``tal:content`` expression::
+
+ "structure python:msg.content.plain(hyperlink=1)"
+
+ Note also that the text is automatically HTML-escaped before
+ the hyperlinking transformation.
+hyperlinked The same as msg.content.plain(hyperlink=1), but nicer::
+
+ "structure msg/content/hyperlinked"
+
+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".
+now only on Date properties - return the current date as a new
+ property
+reldate only on Date properties - render the interval between the date
+ and now
+local only on Date properties - return this date as a new property
+ with some timezone offset
+pretty only on Interval properties - render the interval in a pretty
+ 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 bit 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
+
+Also, the current id of the current user is available as
+``db.getuid()``. This isn't so useful in templates (where you have
+``request/user``), but it can be useful in detectors or interfaces.
+
+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 consequently 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 has one useful attribute, namely "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:utils.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 filters. 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 should be
-displayed in an editable field.
-
-The display of a property is handled by functions in the htmltemplate module.
-Displayer functions are triggered by <display> tags in templates. The call
-attribute of the tag provides a Python expression for calling the displayer
-function. The three standard arguments are inserted in front of the arguments
-given. For example, the occurrence of::
-
- <display call="plain('status')">
-
-in a template triggers a call the "plain" function. The displayer functions can
-accept extra arguments to further specify details about the widgets that should
-be generated. By defining new displayer functions, the user interface can be
-highly customized.
-
-+-----------------------------------------------------------------------------+
-|The displayer functions are |
-+---------+-------------------------------------------------------------------+
-|plain |Display a String property directly. |
-| |Display a Date property in a specified time zone with an option to |
-| |omit the time from the date stamp. |
-| |For a Link or Multilink property, display the key strings of the |
-| |linked nodes (or the ids if the linked class has no key property). |
-| |Options: |
-| |escape (boolean) - HTML-escape the resulting text. |
-+---------+-------------------------------------------------------------------+
-|field |Display a property like the plain displayer above, but in a form |
-| |field to be edited. Strings, Dates and Intervals use TEXT fields, |
-| |Links use SELECT fields and Multilinks use SELECT MULTIPLE fields. |
-| |Options: |
-| |size (number) - width of TEXT fields. |
-| |height (number) - number of nows in SELECT MULTIPLE tags. |
-| |showid (boolean) - true includes the id of linked nodes in the |
-| |SELECT MULTIPLE fields. |
-+---------+-------------------------------------------------------------------+
-|menu |For a Links and Multilinks, display the same field as would be |
-| |generated using field. |
-+---------+-------------------------------------------------------------------+
-|link |For a Link or Multilink property, display the names of the linked |
-| |nodes, hyperlinked to the item views on those nodes. |
-| |For other properties, link to this node with the property as the |
-| |text. |
-| |Options: |
-| |property (property name) - the property to use in the second case. |
-| |showid - use the linked node id as the link text (linked node |
-| |"value" will be set as a tooltip) |
-+---------+-------------------------------------------------------------------+
-|count |For a Multilink property, display a count of the number of links in|
-| |the list. |
-| |Arguments: |
-| |property (property name) - the property to use. |
-+---------+-------------------------------------------------------------------+
-|reldate |Display a Date property in terms of an interval relative to the |
-| |current date (e.g. "+ 3w", "- 2d"). |
-| |Arguments: |
-| |property (property name) - the property to use. |
-| |Options: |
-| |pretty (boolean) - display the relative date in an English form. |
-+---------+-------------------------------------------------------------------+
-|download |For a Link or Multilink property, display the names of the linked |
-| |nodes, hyperlinked to the item views on those nodes. |
-| |For other properties, link to this node with the property as the |
-| |text. |
-| |In all cases, append the name (key property) of the item to the |
-| |path so it is the name of the file being downloaded. |
-| |Arguments: |
-| |property (property name) - the property to use. |
-+---------+-------------------------------------------------------------------+
-|checklist|For a Link or Multilink property, display checkboxes for the |
-| |available choices to permit filtering. |
-| |Arguments: |
-| |property (property name) - the property to use. |
-+---------+-------------------------------------------------------------------+
-|note |Display the special notes field, which is a text area for entering |
-| |a note to go along with a change. |
-+---------+-------------------------------------------------------------------+
-|list |List the nodes specified by property using the standard index for |
-| |the class. |
-| |Arguments: |
-| |property (property name) - the property to use. |
-+---------+-------------------------------------------------------------------+
-|history |List the history of the item. |
-+---------+-------------------------------------------------------------------+
-|submit |Add a submit button for the item. |
-+---------+-------------------------------------------------------------------+
+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".
-An index view contains two sections: a filter section and an index section. The
-filter section provides some widgets for selecting which items appear in the
-index. The index section is a table of items.
Index View Specifiers
-:::::::::::::::::::::
+~~~~~~~~~~~~~~~~~~~~~
-An index view specifier (URL fragment) looks like this (whitespace has been
-added for clarity)::
+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 nodes
-are displayed. The filter part consists of all the other query parameters, and
-it determines the criteria by which nodes 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" nodes. 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.
-
-Associated with each item class is a default layout specifier. The layout
-specifier in the above example is the default layout to be provided with the
-default bug-tracker schema described above in section 4.4.
-
-Filter Section
-::::::::::::::
-
-The template for a filter section provides the filtering widgets at the top of
-the index view. Fragments enclosed in <property>...</property> tags are
-included or omitted depending on whether the view specifier requests a filter
-for a particular property.
-
-A property must appear in the filter template for it to be available as a
-filter.
-
-Here's a simple example of a filter template.::
-
- <property name=status>
- <display call="checklist('status')">
- </property>
- <br>
- <property name=priority>
- <display call="checklist('priority')">
- </property>
- <br>
- <property name=fixer>
- <display call="menu('fixer')">
- </property>
-
-The standard index generation code appends a section to the index pages which
-allows selection of the filters - from those which are defined in the filter
-template.
+ 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-progress" 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
+---------------
-Index Section
-:::::::::::::
+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 that 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 search page should lay out any fields that you wish to allow the
+user to search on. 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 with which to perform a search of the text index. 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.
-The template for an index section describes one row of the index table.
-Fragments enclosed in <property>...</property> tags are included or omitted
-depending on whether the view specifier requests a column for a particular
-property. The table cells should contain <display> tags to display the values
-of the item's properties.
-Here's a simple example of an index template.::
+Item Views
+----------
- <tr>
- <property name=title>
- <td><display call="plain('title', max=50)"></td>
- </property>
- <property name=status>
- <td><display call="plain('status')"></td>
- </property>
- <property name=fixer>
- <td><display call="plain('fixer')"></td>
- </property>
- </tr>
+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.
-Sorting
-:::::::
-String and Date values are sorted in the natural way. Link properties are
-sorted according to the value of the "order" property on the linked nodes if it
-is present; or otherwise on the key string of the linked nodes; or finally on
-the node ids. Multilink properties are sorted according to how many links are
-present.
+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.html"
+template)::
+
+ <table class="form">
+ <tr>
+ <th>Title</th>
+ <td colspan="3" tal:content="structure python:context.title.field(size=60)">title</td>
+ </tr>
+
+ <tr>
+ <th>Priority</th>
+ <td tal:content="structure context/priority/menu">priority</td>
+ <th>Status</th>
+ <td tal:content="structure context/status/menu">status</td>
+ </tr>
+
+ <tr>
+ <th>Superseder</th>
+ <td>
+ <span tal:replace="structure python:context.superseder.field(showid=1, size=20)" />
+ <span tal:replace="structure python:db.issue.classhelp('id,title')" />
+ <span tal:condition="context/superseder">
+ <br>View: <span tal:replace="structure python:context.superseder.link(showid=1)" />
+ </span>
+ </td>
+ <th>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>Assigned To</th>
+ <td tal:content="structure context/assignedto/menu">
+ assignedto menu
+ </td>
+ <td> </td>
+ <td> </td>
+ </tr>
+
+ <tr>
+ <th>Change Note</th>
+ <td colspan="3">
+ <textarea name=":note" wrap="hard" rows="5" cols="60"></textarea>
+ </td>
+ </tr>
+
+ <tr>
+ <th>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>
-Item Views
-~~~~~~~~~~
-An item view contains an editor section and a spool section. At the top of an
-item view, links to superseding and superseded items are always displayed.
+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 changenote message generated by Roundup.
-Editor Section
-::::::::::::::
-The editor section is generated from a template containing <display> tags to
-insert the appropriate widgets for editing properties.
+Form values
+:::::::::::
-Here's an example of a basic editor template.::
+We have a number of ways to pull properties out of the form in order to
+meet the various needs of:
+
+1. editing the current item (perhaps an issue item)
+2. editing information related to the current item (eg. messages or
+ attached files)
+3. creating new information to be linked to the current item (eg. time
+ spent on an issue)
+
+In the following, ``<bracketed>`` values are variable, ":" may be one of
+":" or "@", and other text ("required") is fixed.
+
+Properties are specified as form variables:
+
+``<propname>``
+ property on the current context item
+
+``<designator>:<propname>``
+ property on the indicated item (for editing related information)
+
+``<classname>-<N>:<propname>``
+ property on the Nth new item of classname (generally for creating new
+ items to attach to the current item)
+
+Once we have determined the "propname", we check to see if it is one of
+the special form values:
+
+``@required``
+ The named property values must be supplied or a ValueError will be
+ raised.
+
+``@remove@<propname>=id(s)``
+ The ids will be removed from the multilink property.
+
+``:add:<propname>=id(s)``
+ The ids will be added to the multilink property.
+
+``:link:<propname>=<designator>``
+ Used to add a link to new items created during edit. These are
+ collected and returned in ``all_links``. This will result in an
+ additional linking operation (either Link set or Multilink append)
+ after the edit/create is done using ``all_props`` in ``_editnodes``.
+ The <propname> on the current item will be set/appended the id of the
+ newly created item of class <designator> (where <designator> must be
+ <classname>-<N>).
+
+Any of the form variables may be prefixed with a classname or
+designator.
+
+Two special form values are supported for backwards compatibility:
+
+``:note``
+ create a message (with content, author and date), linked to the
+ context item. This is ALWAYS designated "msg-1".
+``:file``
+ create a file, attached to the current item and any message created by
+ :note. This is ALWAYS designated "file-1".
+
+
+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 `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 (i.e. a
+selection of distinct values) to your tracker.
+
+
+Introduction
+~~~~~~~~~~~~
+
+To make the classic schema of roundup useful as a TODO tracking system
+for a group of systems administrators, it needed an extra data field per
+issue: a category.
+
+This would let sysadmins 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 sysadmins (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. (Since "name" is one of the properties
+that Roundup looks for on items if you do not set a key for them, it's
+probably a good idea to stick with it for new classes if at all
+appropriate.) 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 one-to-one relationship to issues (such
+as the "assignedto" property), 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 has already been initalised, then 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
+
+TODO: explain why order=1 in each case. Also, does key get set to "name"
+automatically when added via roundup-admin?
+
+
+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 (with 'cl' substituted by its
+value)::
+
+ 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 for 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 - that the user has "Edit" permissions for
+the "category" objects - is matched. 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 to *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 of the *category* object. This
+translates into Roundup looking for a file called ``category.item.html``
+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 - "name" - so
+they 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 field into which 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>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">
+
+ <table class="form">
+ <tr><th class="header" colspan="2">Category</th></tr>
- <table>
- <tr>
- <td colspan=2>
- <display call="field('title', size=60)">
- </td>
- </tr>
- <tr>
- <td>
- <display call="field('fixer', size=30)">
- </td>
- <td>
- <display call="menu('status')>
- </td>
- </tr>
<tr>
- <td>
- <display call="field('nosy', size=30)">
- </td>
- <td>
- <display call="menu('priority')>
- </td>
+ <th>Name</th>
+ <td tal:content="structure python:context.name.field(size=60)">
+ name</td>
</tr>
+
<tr>
- <td colspan=2>
- <display call="note()">
- </td>
+ <td>
+
+ <input type="hidden" name="@required" value="name">
+ </td>
+ <td colspan="3" tal:content="structure context/submit">
+ submit button will go here
+ </td>
</tr>
- </table>
+ </table>
+ </form>
+ </td>
+ </tal:block>
-As shown in the example, the editor template can also request the display of a
-"note" field, which is a text area for entering a note to go along with a
-change.
+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).
-The <property> tag used in the index may also be used here - it checks to see
-if the nominated Multilink property has any entries. This can be used to
-eliminate sections of the editor section if the property has no entries::
- <td class="form-text">
- <display call="field('superseder', size=40, showid=1)">
- <display call="classhelp('issue', 'id,title', label='list', width=500)">
- <property name="superseder">
- <br>View: <display call="link('superseder', showid=1)">
- </property>
- </td>
+Adding the category to the issue
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-The "View: " part with the links will only display if the superseder property
-has values.
+We now have the ability to create issues to our heart's content, but
+that is pointless unless we can assign categories to issues. Just like
+the ``html/category.item.html`` file was used to define how to add a new
+category, the ``html/issue.item.html`` is used to define how a new issue
+is created.
-When a change is submitted, the system automatically generates a message
-describing the changed properties.
+Just like ``category.issue.html`` 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::
-If a note is given in the "note" field, the note is appended to the
-description. The message is then added to the item's message spool (thus
-triggering the standard detector to react by sending out this message to the
-nosy list).
+ <th>Category</th>
+ <td><span tal:replace="structure context/category/field" />
+ <span tal:replace="structure db/category/classhelp" />
+ </td>
-The message also displays all of the property values on the item and indicates
-which ones have changed. An example of such a message might be this::
+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 by a field which contains
+the category in the current context (the current context being the new
+issue).
- Polly's taken a turn for the worse - this is now really important!
- -----
- title: Polly Parrot is dead
- priority: critical
- status: unread -> in-progress
- fixer: terry
- keywords: parrot,plumage,perch,nailed,dead
+The classhelp lines generate a link (labelled "list") to a popup window
+which contains the list of currently known categories.
-Spool Section
-:::::::::::::
-The spool section lists messages in the item's "messages" property. The index
-of messages displays the "date", "author", and "summary" properties on the
-message nodes, and selecting a message takes you to its content.
+Searching on categories
+~~~~~~~~~~~~~~~~~~~~~~~
-The <property> tag used in the index may also be used here - it checks to see
-if the nominated Multilink property has any entries. This can be used to
-eliminate sections of the spool section if the property has no entries::
+We can add categories, and create issues with categories. The next
+obvious thing that we would like to be able to do, would be to search
+for issues based on their category, so that, for example, anyone working
+on the web server could look at all issues in the category "Web".
- <property name="files">
- <tr class="strong-header">
- <td><b>Files</b></td>
- </tr>
+If you look for "Search Issues" in the 'html/page.html' file, you will
+find that it looks something like
+``<a href="issue?@template=search">Search Issues</a>``. This shows us
+that when you click on "Search Issues" it will be looking for a
+``issue.search.html`` file to display. So that is the file that we will
+change.
+
+If you look at this file it should be starting to seem familiar, although it
+does use some new macros. You can add the new category search code anywhere you
+like within that form::
+
+ <tr tal:define="name string:category;
+ db_klass string:category;
+ db_content string:name;">
+ <th>Priority:</th>
+ <td metal:use-macro="search_select"></td>
+ <td metal:use-macro="column_input"></td>
+ <td metal:use-macro="sort_input"></td>
+ <td metal:use-macro="group_input"></td>
+ </tr>
+
+The definitions in the <tr> opening tag are used by the macros:
+
+- search_select expands to a drop-down box with all categories using db_klass
+ and db_content.
+- column_input expands to a checkbox for selecting what columns should be
+ displayed.
+- sort_input expands to a radio button for selecting what property should be
+ sorted on.
+- group_input expands to a radio button for selecting what property should be
+ group on.
+
+The category search code above would expand to the following::
+
+ <tr>
+ <th>Category:</th>
+ <td>
+ <select name="category">
+ <option value="">don't care</option>
+ <option value="">------------</option>
+ <option value="1">scipy</option>
+ <option value="2">chaco</option>
+ <option value="3">weave</option>
+ </select>
+ </td>
+ <td><input type="checkbox" name=":columns" value="category"></td>
+ <td><input type="radio" name=":sort" value="category"></td>
+ <td><input type="radio" name=":group" value="category"></td>
+ </tr>
+
+Adding category to the default view
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+We can now add categories, add issues with categories, and search for
+issues based on categories. This is everything that we need to do;
+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.html``. This
+is a large table with a form down at 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 to avoid displaying the
+Category column if the user has selected not to see it.
+
+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.html`` again. This time, we need to
+tell it that when the user clicks on "Unasigned Issues" or "All Issues",
+the category column should be included in the resulting list. 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. You can do this by following these steps:
+
+1. make "status" a required variable. This is achieved by adding the
+ following to the top of the form in the ``issue.item.html``
+ template::
+
+ <input type="hidden" name="@required" value="status">
+
+ this will force users to select a status.
+
+2. add a Multilink property to the status class::
+
+ stat = Class(db, "status", ... , transitions=Multilink('status'),
+ ...)
+
+ and then edit the statuses already created, either:
+
+ a. through the web using the class list -> status class editor, or
+ b. using the roundup-admin "set" command.
+
+3. add an auditor module ``checktransition.py`` in your tracker's
+ ``detectors`` directory, for example::
+
+ def checktransition(db, cl, nodeid, newvalues):
+ ''' Check that the desired transition is valid for the "status"
+ property.
+ '''
+ if not newvalues.has_key('status'):
+ return
+ current = cl.get(nodeid, 'status')
+ new = newvalues['status']
+ if new == current:
+ return
+ ok = db.status.get(current, 'transitions')
+ if new not in ok:
+ raise ValueError, 'Status not allowed to move from "%s" to "%s"'%(
+ db.status.get(current, 'name'), db.status.get(new, 'name'))
+
+ def init(db):
+ db.issue.audit('set', checktransition)
+
+4. in the ``issue.item.html`` template, change the status editing bit
+ from::
+
+ <th>Status</th>
+ <td tal:content="structure context/status/menu">status</td>
+
+ to::
+
+ <th>Status</th>
+ <td>
+ <select tal:condition="context/id" name="status">
+ <tal:block tal:define="ok context/status/transitions"
+ tal:repeat="state db/status/list">
+ <option tal:condition="python:state.id in ok"
+ tal:attributes="
+ value state/id;
+ selected python:state.id == context.status.id"
+ tal:content="state/name"></option>
+ </tal:block>
+ </select>
+ <tal:block tal:condition="not:context/id"
+ tal:replace="structure context/status/menu" />
+ </td>
+
+ which displays only the allowed status to transition to.
+
+
+Displaying only message summaries in the issue display
+------------------------------------------------------
+
+Alter the issue.item template section for messages to::
+
+ <table class="messages" tal:condition="context/messages">
+ <tr><th colspan="5" class="header">Messages</th></tr>
+ <tr tal:repeat="msg context/messages">
+ <td><a tal:attributes="href string:msg${msg/id}"
+ tal:content="string:msg${msg/id}"></a></td>
+ <td tal:content="msg/author">author</td>
+ <td class="date" tal:content="msg/date/pretty">date</td>
+ <td tal:content="msg/summary">summary</td>
+ <td>
+ <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">
+ remove</a>
+ </td>
+ </tr>
+ </table>
+
+Restricting the list of users that are assignable to a task
+-----------------------------------------------------------
+
+1. In your tracker's "dbinit.py", create a new Role, say "Developer"::
+
+ db.security.addRole(name='Developer', description='A developer')
+
+2. Just after that, create a new Permission, say "Fixer", specific to
+ "issue"::
+
+ p = db.security.addPermission(name='Fixer', klass='issue',
+ description='User is allowed to be assigned to fix issues')
+
+3. Then assign the new Permission to your "Developer" Role::
+
+ db.security.addPermissionToRole('Developer', p)
+
+4. In the issue item edit page ("html/issue.item.html" in your tracker
+ directory), use the new Permission in restricting the "assignedto"
+ list::
+
+ <select name="assignedto">
+ <option value="-1">- no selection -</option>
+ <tal:block tal:repeat="user db/user/list">
+ <option tal:condition="python:user.hasPermission(
+ 'Fixer', context._classname)"
+ tal:attributes="
+ value user/id;
+ selected python:user.id == context.assignedto"
+ tal:content="user/realname"></option>
+ </tal:block>
+ </select>
+
+For extra security, you may wish to setup an auditor to enforce the
+Permission requirement (install this as "assignedtoFixer.py" in your
+tracker "detectors" directory)::
+
+ def assignedtoMustBeFixer(db, cl, nodeid, newvalues):
+ ''' Ensure the assignedto value in newvalues is a used with the
+ Fixer Permission
+ '''
+ if not newvalues.has_key('assignedto'):
+ # don't care
+ return
+
+ # get the userid
+ userid = newvalues['assignedto']
+ if not db.security.hasPermission('Fixer', userid, cl.classname):
+ raise ValueError, 'You do not have permission to edit %s'%cl.classname
+
+ def init(db):
+ db.issue.audit('set', assignedtoMustBeFixer)
+ db.issue.audit('create', assignedtoMustBeFixer)
+
+So now, if an edit action attempts to set "assignedto" to a user that
+doesn't have the "Fixer" Permission, the error will be raised.
+
+
+Setting up a "wizard" (or "druid") for controlled adding of issues
+------------------------------------------------------------------
+
+1. Set up the page templates you wish to use for data input. My wizard
+ is going to be a two-step process: first figuring out what category
+ of issue the user is submitting, and then getting details specific to
+ that category. The first page includes a table of help, explaining
+ what the category names mean, and then the core of the form::
+
+ <form method="POST" onSubmit="return submit_once()"
+ enctype="multipart/form-data">
+ <input type="hidden" name="@template" value="add_page1">
+ <input type="hidden" name="@action" value="page1submit">
+
+ <strong>Category:</strong>
+ <tal:block tal:replace="structure context/category/menu" />
+ <input type="submit" value="Continue">
+ </form>
+
+ The next page has the usual issue entry information, with the
+ addition of the following form fragments::
+
+ <form method="POST" onSubmit="return submit_once()"
+ enctype="multipart/form-data"
+ tal:condition="context/is_edit_ok"
+ tal:define="cat request/form/category/value">
+
+ <input type="hidden" name="@template" value="add_page2">
+ <input type="hidden" name="@required" value="title">
+ <input type="hidden" name="category" tal:attributes="value cat">
+ .
+ .
+ .
+ </form>
+
+ Note that later in the form, I test the value of "cat" include form
+ elements that are appropriate. For example::
+
+ <tal:block tal:condition="python:cat in '6 10 13 14 15 16 17'.split()">
+ <tr>
+ <th>Operating System</th>
+ <td tal:content="structure context/os/field"></td>
+ </tr>
+ <tr>
+ <th>Web Browser</th>
+ <td tal:content="structure context/browser/field"></td>
+ </tr>
+ </tal:block>
+
+ ... 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())
- <tr>
- <td><display call="list('files')"></td>
+2. So that users may edit the vacation flags, add something like the
+ following to your ``user.item`` template::
+
+ <tr>
+ <th>On Vacation</th>
+ <td tal:content="structure context/vacation/field">vacation</td>
+ </tr>
+
+3. edit your detector ``nosyreactor.py`` so that the ``nosyreaction()``
+ consists of::
+
+ def nosyreaction(db, cl, nodeid, oldvalues):
+ # send a copy of all new messages to the nosy list
+ for msgid in determineNewMessages(cl, nodeid, oldvalues):
+ try:
+ users = db.user
+ messages = db.msg
+
+ # figure the recipient ids
+ sendto = []
+ r = {}
+ recipients = messages.get(msgid, 'recipients')
+ for recipid in messages.get(msgid, 'recipients'):
+ r[recipid] = 1
+
+ # figure the author's id, and indicate they've received
+ # the message
+ authid = messages.get(msgid, 'author')
+
+ # possibly send the message to the author, as long as
+ # they aren't anonymous
+ if (db.config.MESSAGES_TO_AUTHOR == 'yes' and
+ users.get(authid, 'username') != 'anonymous'):
+ sendto.append(authid)
+ r[authid] = 1
+
+ # now figure the nosy people who weren't recipients
+ nosy = cl.get(nodeid, 'nosy')
+ for nosyid in nosy:
+ # Don't send nosy mail to the anonymous user (that
+ # user shouldn't appear in the nosy list, but just
+ # in case they do...)
+ if users.get(nosyid, 'username') == 'anonymous':
+ continue
+ # make sure they haven't seen the message already
+ if not r.has_key(nosyid):
+ # send it to them
+ sendto.append(nosyid)
+ recipients.append(nosyid)
+
+ # generate a change note
+ if oldvalues:
+ note = cl.generateChangeNote(nodeid, oldvalues)
+ else:
+ note = cl.generateCreateNote(nodeid)
+
+ # we have new recipients
+ if sendto:
+ # filter out the people on vacation
+ sendto = [i for i in sendto
+ if not users.get(i, 'vacation', 0)]
+
+ # map userids to addresses
+ sendto = [users.get(i, 'address') for i in sendto]
+
+ # update the message's recipients list
+ messages.set(msgid, recipients=recipients)
+
+ # send the message
+ cl.send_message(nodeid, msgid, note, sendto)
+ except roundupdb.MessageSendError, message:
+ raise roundupdb.DetectorError, message
+
+ Note that this is the standard nosy reaction code, with the small
+ addition of::
+
+ # filter out the people on vacation
+ sendto = [i for i in sendto if not users.get(i, 'vacation', 0)]
+
+ which filters out the users that have the vacation flag set to true.
+
+
+Adding a time log to your issues
+--------------------------------
+
+We want to log the dates and amount of time spent working on issues, and
+be able to give a summary of the total time spent on a particular issue.
+
+1. Add a new class to your tracker ``dbinit.py``::
+
+ # storage for time logging
+ timelog = Class(db, "timelog", period=Interval())
+
+ Note that we automatically get the date of the time log entry
+ creation through the standard property "creation".
+
+2. Link to the new class from your issue class (again, in
+ ``dbinit.py``)::
+
+ issue = IssueClass(db, "issue",
+ assignedto=Link("user"), topic=Multilink("keyword"),
+ priority=Link("priority"), status=Link("status"),
+ times=Multilink("timelog"))
+
+ the "times" property is the new link to the "timelog" class.
+
+3. We'll need to let people add in times to the issue, so in the web
+ interface we'll have a new entry field. This is a special field
+ because unlike the other fields in the issue.item template, it
+ affects a different item (a timelog item) and not the template's
+ item, an issue. We have a special syntax for form fields that affect
+ items other than the template default item (see the cgi
+ documentation on `special form variables`_). In particular, we add a
+ field to capture a new timelog item's perdiod::
+
+ <tr>
+ <th>Time Log</th>
+ <td colspan=3><input type="text" name="timelog-1@period" />
+ <br />(enter as '3y 1m 4d 2:40:02' or parts thereof)
+ </td>
+ </tr>
+
+ and another hidden field that links that new timelog item (new
+ because it's marked as having id "-1") to the issue item. It looks
+ like this::
+
+ <input type="hidden" name="@link@times" value="timelog-1" />
+
+ On submission, the "-1" timelog item will be created and assigned a
+ real item id. The "times" property of the issue will have the new id
+ added to it.
+
+4. We want to display a total of the time log times that have been
+ accumulated for an issue. To do this, we'll need to actually write
+ some Python code, since it's beyond the scope of PageTemplates to
+ perform such calculations. We do this by adding a method to the
+ TemplatingUtils class in our tracker ``interfaces.py`` module::
+
+ class TemplatingUtils:
+ ''' Methods implemented on this class will be available to HTML
+ templates through the 'utils' variable.
+ '''
+ def totalTimeSpent(self, times):
+ ''' Call me with a list of timelog items (which have an
+ Interval "period" property)
+ '''
+ total = Interval('')
+ for time in times:
+ total += time.period._value
+ return total
+
+ Replace the ``pass`` line as we did in step 4 above with the Client
+ class. As indicated in the docstrings, we will be able to access the
+ ``totalTimeSpent`` method via the ``utils`` variable in our
+ templates.
+
+5. Display the time log for an issue::
+
+ <table class="otherinfo" tal:condition="context/times">
+ <tr><th colspan="3" class="header">Time Log
+ <tal:block
+ tal:replace="python:utils.totalTimeSpent(context.times)" />
+ </th></tr>
+ <tr><th>Date</th><th>Period</th><th>Logged By</th></tr>
+ <tr tal:repeat="time context/times">
+ <td tal:content="time/creation"></td>
+ <td tal:content="time/period"></td>
+ <td tal:content="time/creator"></td>
</tr>
- </property>
+ </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!
+
+
+Using an LDAP database for user information
+-------------------------------------------
+
+A script that reads users from an LDAP store using
+http://python-ldap.sf.net/ and then compares the list to the users in the
+roundup user database would be pretty easy to write. You'd then have it run
+once an hour / day (or on demand if you can work that into your LDAP store
+workflow). See the example `Using a UN*X passwd file as the user database`_
+for more information about doing this.
+
+To authenticate off the LDAP store (rather than using the passwords in the
+roundup user database) you'd use the same python-ldap module inside an
+extension to the cgi interface. You'd do this by adding a method called
+"verifyPassword" to the Client class in your tracker's interfaces.py
+module. The method is implemented by default as::
+
+ def verifyPassword(self, userid, password):
+ ''' Verify the password that the user has supplied
+ '''
+ stored = self.db.user.get(self.userid, 'password')
+ if password == stored:
+ return 1
+ if not password and not stored:
+ return 1
+ return 0
+
+So you could reimplement this as something like::
+
+ def verifyPassword(self, userid, password):
+ ''' Verify the password that the user has supplied
+ '''
+ # look up some unique LDAP information about the user
+ username = self.db.user.get(self.userid, 'username')
+ # now verify the password supplied against the LDAP store
+
+
+Enabling display of either message summaries or the entire messages
+-------------------------------------------------------------------
+
+This is pretty simple - all we need to do is copy the code from the
+example `displaying only message summaries in the issue display`_ into
+our template alongside the summary display, and then introduce a switch
+that shows either one or the other. We'll use a new form variable,
+``@whole_messages`` to achieve this::
+
+ <table class="messages" tal:condition="context/messages">
+ <tal:block tal:condition="not:request/form/@whole_messages/value | python:0">
+ <tr><th colspan="3" class="header">Messages</th>
+ <th colspan="2" class="header">
+ <a href="?@whole_messages=yes">show entire messages</a>
+ </th>
+ </tr>
+ <tr tal:repeat="msg context/messages">
+ <td><a tal:attributes="href string:msg${msg/id}"
+ tal:content="string:msg${msg/id}"></a></td>
+ <td tal:content="msg/author">author</td>
+ <td class="date" tal:content="msg/date/pretty">date</td>
+ <td tal:content="msg/summary">summary</td>
+ <td>
+ <a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>
+ </td>
+ </tr>
+ </tal:block>
+
+ <tal:block tal:condition="request/form/@whole_messages/value | python:0">
+ <tr><th colspan="2" class="header">Messages</th>
+ <th class="header">
+ <a href="?@whole_messages=">show only summaries</a>
+ </th>
+ </tr>
+ <tal:block tal:repeat="msg context/messages">
+ <tr>
+ <th tal:content="msg/author">author</th>
+ <th class="date" tal:content="msg/date/pretty">date</th>
+ <th style="text-align: right">
+ (<a tal:attributes="href string:?@remove@messages=${msg/id}&@action=edit">remove</a>)
+ </th>
+ </tr>
+ <tr><td colspan="3" tal:content="msg/content"></td></tr>
+ </tal:block>
+ </tal:block>
+ </table>
+
+
+Blocking issues that depend on other issues
+-------------------------------------------
+
+We needed the ability to mark certain issues as "blockers" - that is,
+they can't be resolved until another issue (the blocker) they rely on is
+resolved. To achieve this:
+
+1. Create a new property on the issue Class,
+ ``blockers=Multilink("issue")``. Edit your tracker's dbinit.py file.
+ Where the "issue" class is defined, something like::
+
+ issue = IssueClass(db, "issue",
+ assignedto=Link("user"), topic=Multilink("keyword"),
+ priority=Link("priority"), status=Link("status"))
+
+ add the blockers entry like so::
+
+ issue = IssueClass(db, "issue",
+ blockers=Multilink("issue"),
+ assignedto=Link("user"), topic=Multilink("keyword"),
+ priority=Link("priority"), status=Link("status"))
+
+2. Add the new "blockers" property to the issue.item edit page, using
+ something like::
+
+ <th>Waiting On</th>
+ <td>
+ <span tal:replace="structure python:context.blockers.field(showid=1,
+ size=20)" />
+ <span tal:replace="structure python:db.issue.classhelp('id,title')" />
+ <span tal:condition="context/blockers"
+ tal:repeat="blk context/blockers">
+ <br>View: <a tal:attributes="href string:issue${blk/id}"
+ tal:content="blk/id"></a>
+ </span>
+
+ You'll need to fiddle with your item page layout to find an
+ appropriate place to put it - I'll leave that fun part up to you.
+ Just make sure it appears in the first table, possibly somewhere near
+ the "superseders" field.
+
+3. Create a new detector module (attached) which enforces the rules:
+
+ - issues may not be resolved if they have blockers
+ - when a blocker is resolved, it's removed from issues it blocks
+
+ The contents of the detector should be something like this::
+
+ def blockresolution(db, cl, nodeid, newvalues):
+ ''' If the issue has blockers, don't allow it to be resolved.
+ '''
+ if nodeid is None:
+ blockers = []
+ else:
+ blockers = cl.get(nodeid, 'blockers')
+ blockers = newvalues.get('blockers', blockers)
+
+ # don't do anything if there's no blockers or the status hasn't
+ # changed
+ if not blockers or not newvalues.has_key('status'):
+ return
+
+ # get the resolved state ID
+ resolved_id = db.status.lookup('resolved')
+
+ # format the info
+ u = db.config.TRACKER_WEB
+ s = ', '.join(['<a href="%sissue%s">%s</a>'%(
+ u,id,id) for id in blockers])
+ if len(blockers) == 1:
+ s = 'issue %s is'%s
+ else:
+ s = 'issues %s are'%s
+
+ # ok, see if we're trying to resolve
+ if newvalues['status'] == resolved_id:
+ raise ValueError, "This issue can't be resolved until %s resolved."%s
+
+ def resolveblockers(db, cl, nodeid, newvalues):
+ ''' When we resolve an issue that's a blocker, remove it from the
+ blockers list of the issue(s) it blocks.
+ '''
+ if not newvalues.has_key('status'):
+ return
+
+ # get the resolved state ID
+ resolved_id = db.status.lookup('resolved')
+
+ # interesting?
+ if newvalues['status'] != resolved_id:
+ return
+
+ # yes - find all the blocked issues, if any, and remove me from
+ # their blockers list
+ issues = cl.find(blockers=nodeid)
+ for issueid in issues:
+ blockers = cl.get(issueid, 'blockers')
+ if nodeid in blockers:
+ blockers.remove(nodeid)
+ cl.set(issueid, blockers=blockers)
+
+
+ def init(db):
+ # might, in an obscure situation, happen in a create
+ db.issue.audit('create', blockresolution)
+ db.issue.audit('set', blockresolution)
+
+ # can only happen on a set
+ db.issue.react('set', resolveblockers)
+
+ Put the above code in a file called "blockers.py" in your tracker's
+ "detectors" directory.
+
+4. Finally, and this is an optional step, modify the tracker web page
+ URLs so they filter out issues with any blockers. You do this by
+ adding an additional filter on "blockers" for the value "-1". For
+ example, the existing "Show All" link in the "page" template (in the
+ tracker's "html" directory) looks like this::
+
+ <a href="issue?:sort=-activity&:group=priority&:filter=status&:columns=id,activity,title,creator,assignedto,status&status=-1,1,2,3,4,5,6,7">Show All</a><br>
+
+ modify it to add the "blockers" info to the URL (note, both the
+ ":filter" *and* "blockers" values must be specified)::
+
+ <a href="issue?:sort=-activity&:group=priority&:filter=status,blockers&blockers=-1&:columns=id,activity,title,creator,assignedto,status&status=-1,1,2,3,4,5,6,7">Show All</a><br>
+
+That's it. You should now be able to set blockers on your issues. Note
+that if you want to know whether an issue has any other issues dependent
+on it (i.e. it's in their blockers list) you can look at the journal
+history at the bottom of the issue page - look for a "link" event to
+another issue's "blockers" property.
+
+Add users to the nosy list based on the topic
+---------------------------------------------
+
+We need the ability to automatically add users to the nosy list based
+on the occurence of a topic. Every user should be allowed to edit his
+own list of topics for which he wants to be added to the nosy list.
+
+Below will be showed that such a change can be performed with only
+minimal understanding of the roundup system, but with clever use
+of Copy and Paste.
+
+This requires three changes to the tracker: a change in the database to
+allow per-user recording of the lists of topics for which he wants to
+be put on the nosy list, a change in the user view allowing to edit
+this list of topics, and addition of an auditor which updates the nosy
+list when a topic is set.
+
+Adding the nosy topic list
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The change in the database to make is that for any user there should be
+a list of topics for which he wants to be put on the nosy list. Adding
+a ``Multilink`` of ``keyword`` seem to fullfill this (note that within
+the code topics are called ``keywords``.) As such, all what has to be
+done is to add a new field to the definition of ``user`` within the
+file ``dbinit.py``. We will call this new field ``nosy_keywords``, and
+the updated definition of user will be::
+
+ user = Class(db, "user",
+ username=String(), password=Password(),
+ address=String(), realname=String(),
+ phone=String(), organisation=String(),
+ alternate_addresses=String(),
+ queries=Multilink('query'), roles=String(),
+ timezone=String(),
+ nosy_keywords=Multilink('keyword'))
+
+Changing the user view to allow changing the nosy topic list
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+We want any user to be able to change the list of topics for which
+he will by default be added to the nosy list. We choose to add this
+to the user view, as is generated by the file ``html/user.item.html``.
+We easily can
+see that the topic field in the issue view has very similar editting
+requirements as our nosy topics, both being a list of topics. As
+such, we search for Topics in ``issue.item.html``, and extract the
+associated parts from there. We add this to ``user.item.html`` at the
+bottom of the list of viewed items (i.e. just below the 'Alternate
+E-mail addresses' in the classic template)::
+
+ <tr>
+ <th>Nosy Topics</th>
+ <td>
+ <span tal:replace="structure context/nosy_keywords/field" />
+ <span tal:replace="structure python:db.keyword.classhelp(property='nosy_keywords')" />
+ </td>
+ </tr>
+
+
+Addition of an auditor to update the nosy list
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The more difficult part is the addition of the logic to actually
+at the users to the nosy list when it is required.
+The choice is made to perform this action when the topics on an
+item are set, including when an item is created.
+Here we choose to start out with a copy of the
+``detectors/nosyreaction.py`` detector, which we copy to the file
+``detectors/nosy_keyword_reaction.py``.
+This looks like a good start as it also adds users
+to the nosy list. A look through the code reveals that the
+``nosyreaction`` function actually is sending the e-mail, which
+we do not need. As such, we can change the init function to::
+
+ def init(db):
+ db.issue.audit('create', update_kw_nosy)
+ db.issue.audit('set', update_kw_nosy)
+
+After that we rename the ``updatenosy`` function to ``update_kw_nosy``.
+The first two blocks of code in that function relate to settings
+``current`` to a combination of the old and new nosy lists. This
+functionality is left in the new auditor. The following block of
+code, which in ``updatenosy`` handled adding the assignedto user(s)
+to the nosy list, should be replaced by a block of code to add the
+interested users to the nosy list. We choose here to loop over all
+new topics, than loop over all users,
+and assign the user to the nosy list when the topic in the user's
+nosy_keywords. The next part in ``updatenosy``, adding the author
+and/or recipients of a message to the nosy list, obviously is not
+relevant here and thus is deleted from the new auditor. The last
+part, copying the new nosy list to newvalues, does not have to be changed.
+This brings the following function::
+
+ def update_kw_nosy(db, cl, nodeid, newvalues):
+ '''Update the nosy list for changes to the topics
+ '''
+ # nodeid will be None if this is a new node
+ current = {}
+ if nodeid is None:
+ ok = ('new', 'yes')
+ else:
+ ok = ('yes',)
+ # old node, get the current values from the node if they haven't
+ # changed
+ if not newvalues.has_key('nosy'):
+ nosy = cl.get(nodeid, 'nosy')
+ for value in nosy:
+ if not current.has_key(value):
+ current[value] = 1
+
+ # if the nosy list changed in this transaction, init from the new value
+ if newvalues.has_key('nosy'):
+ nosy = newvalues.get('nosy', [])
+ for value in nosy:
+ if not db.hasnode('user', value):
+ continue
+ if not current.has_key(value):
+ current[value] = 1
+
+ # add users with topic in nosy_keywords to the nosy list
+ if newvalues.has_key('topic') and newvalues['topic'] is not None:
+ topic_ids = newvalues['topic']
+ for topic in topic_ids:
+ # loop over all users,
+ # and assign user to nosy when topic in nosy_keywords
+ for user_id in db.user.list():
+ nosy_kw = db.user.get(user_id, "nosy_keywords")
+ found = 0
+ for kw in nosy_kw:
+ if kw == topic:
+ found = 1
+ if found:
+ current[user_id] = 1
+
+ # that's it, save off the new nosy list
+ newvalues['nosy'] = current.keys()
+
+and these two function are the only ones needed in the file.
+
+TODO: update this example to use the find() Class method.
+
+Caveats
+~~~~~~~
+
+A few problems with the design here can be noted:
+
+Multiple additions
+ When a user, after automatic selection, is manually removed
+ from the nosy list, he again is added to the nosy list when the
+ topic list of the issue is updated. A better design might be
+ to only check which topics are new compared to the old list
+ of topics, and only add users when they have indicated
+ interest on a new topic.
+
+ The code could also be changed to only trigger on the create() event,
+ rather than also on the set() event, thus only setting the nosy list
+ when the issue is created.
+
+Scalability
+ In the auditor there is a loop over all users. For a site with
+ only few users this will pose no serious problem, however, with
+ many users this will be a serious performance bottleneck.
+ A way out will be to link from the topics to the users which
+ selected these topics a nosy topics. This will eliminate the
+ loop over all users.
+
+
+Adding action links to the index page
+-------------------------------------
+
+Add a column to the item.index.html template.
+
+Resolving the issue::
+
+ <a tal:attributes="href
+ string:issue${i/id}?:status=resolved&:action=edit">resolve</a>
+
+"Take" the issue::
+
+ <a tal:attributes="href
+ string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a>
+
+... and so on
+
+-------------------
Back to `Table of Contents`_
.. _`Table of Contents`: index.html
+.. _`design documentation`: design.html