summary | shortlog | log | commit | commitdiff | tree
raw | patch | inline | side by side (parent: 71e7733)
raw | patch | inline | side by side (parent: 71e7733)
author | richard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2> | |
Thu, 19 Jul 2001 02:16:19 +0000 (02:16 +0000) | ||
committer | richard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2> | |
Thu, 19 Jul 2001 02:16:19 +0000 (02:16 +0000) |
which included commits to RCS files with non-trunk default branches.
git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@3 57a73879-2fb5-44c3-a270-3262357dd7e2
git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@3 57a73879-2fb5-44c3-a270-3262357dd7e2
22 files changed:
CHANGES | [new file with mode: 0644] | patch | blob |
README | [new file with mode: 0644] | patch | blob |
cgitb.py | [new file with mode: 0644] | patch | blob |
config.py | [new file with mode: 0644] | patch | blob |
date.py | [new file with mode: 0644] | patch | blob |
hyperdb.py | [new file with mode: 0644] | patch | blob |
roundup-mailgw.py | [new file with mode: 0755] | patch | blob |
roundup.cgi | [new file with mode: 0755] | patch | blob |
roundup.py | [new file with mode: 0755] | patch | blob |
roundup_cgi.py | [new file with mode: 0644] | patch | blob |
roundupdb.py | [new file with mode: 0644] | patch | blob |
server.py | [new file with mode: 0755] | patch | blob |
style.css | [new file with mode: 0644] | patch | blob |
template.py | [new file with mode: 0644] | patch | blob |
templates/file.index | [new file with mode: 0644] | patch | blob |
templates/issue.filter | [new file with mode: 0644] | patch | blob |
templates/issue.index | [new file with mode: 0644] | patch | blob |
templates/issue.item | [new file with mode: 0644] | patch | blob |
templates/msg.index | [new file with mode: 0644] | patch | blob |
templates/msg.item | [new file with mode: 0644] | patch | blob |
templates/user.index | [new file with mode: 0644] | patch | blob |
templates/user.item | [new file with mode: 0644] | patch | blob |
diff --git a/CHANGES b/CHANGES
--- /dev/null
+++ b/CHANGES
@@ -0,0 +1,45 @@
+2001-07-11 - 0.1.0
+ . Needed a bug tracking system. Looked around. Tried to install many
+ Perl-based systems, to no avail. Got tired of waiting for Roundup to be
+ released. Had just finished major product project, so needed something
+ different for a while. Roundup here I come...
+
+
+2001-07-18 - 0.1.1
+ . Initial version release with consent of Roundup spec author, Ka-Ping Yee:
+ "Amazing! Nice work. I'll watch for the source code on your website."
+
+
+2001-07-18 - 0.1.2
+ . Set default index to ?:group=priority&:columns=activity,status,title so
+ the priority column isn't displayed.
+ . Thanks Anthony:
+ . added notes to the README about Python prerequisites
+ . added check to roundup.py, roundup.cgi, server.py and roundup-mailgw.py
+ for python 2+ - and made the file itself parseable by 1.5.2 ;)
+ . python 2.0 didn't have the default args for the time module functions.
+ . better handling of db directory in initDB
+ . Sorting on the extra properties defined by roundupdb classes was broken
+ due to the caching used. May now sort on activity and creation
+ properties, etc.
+ . Set the default index to sort on activity
+
+2001-07-XX - 0.1.3
+ . Reldate now takes an argument "pretty" - when true, it pretty-prints the
+ interval generated up to 5 days, then pretty-prints the date of last
+ activity. The issue index and item now use the pretty format.
+ . Classes list for admin user in CGI interface.
+ . Made the view configuration more accessible, neater and more realistic.
+ . Fixed list view grouping handling grouping by a Multilink or String or Link
+ value of None or Date, ... (mind you, sorting by Date???)
+ . Fixed bug in the plain formatter when a Link was None.
+ . Fixed ordering of list view column headings.
+ . Fixed list view column heading sort links - and limited the number of
+ columns to sort by to 2.
+ . Added searching by glob to StringType filtering -
+ ^text - search for text at start of fields
+ text$ - search for text at end of fields
+ ^text$ - exactly match text in fields
+ te*xt - search for text matching "te"<any characters>"xt"
+ te?xt - search for text matching "te"<any one character>"xt"
+ . Added more fields to the issue.filter and issue.index templates
diff --git a/README b/README
--- /dev/null
+++ b/README
@@ -0,0 +1,218 @@
+ Roundup
+ =======
+
+
+1. License
+==========
+This software is released under the GNU GPL. The copyright is held by Bizar
+Software Pty Ltd (http://www.bizarsoftware.com.au).
+
+The stylesheet included with this package has been copied from the Zope
+management interface and presumably belongs to Digital Creations.
+
+
+
+2. Installation
+===============
+These instructions work on redhat 6.2 and mandrake 8.0 - with the caveat
+that these systems don't come with python 2.0 or newer installed, so you'll
+have to upgrade python before this stuff will work.
+
+Note that most of the following is configurable in the config.py, it's just
+not documented. At a minimum, you'll want to change the email addresses and
+mail host specification in the config.
+
+
+2.0 Prerequisites
+-----------------
+Either:
+ . Python 2.0 with pydoc installed. See http://www.lfw.org/ for pydoc.
+or
+ . Python 2.1
+
+Both need the bsddb module.
+
+
+2.1 Initial Setup
+-----------------
+ 1. Make a directory in /home/httpd/html called 'roundup'.
+ 2. Copy the tar file's contents there.
+ 3. "python roundup.py init" to initialise the database (by default, it
+ goes in a directory called 'db' in the current directory). Choose a
+ sensible admin password.
+ 4. "chmod -R a+rw db"
+
+
+2.2 Mail
+--------
+Set up a mail alias called "issue_tracker" as:
+ "|/usr/bin/python /home/httpd/html/roundup/roundup-mailgw.py"
+
+In some installations (e.g. RedHat 6.2 I think) you'll need to set up smrsh
+so sendmail will accept the pipe command. In that case, symlink
+/etc/smrsh/python to /usr/bin/python and change the command to:
+ "|python /home/httpd/html/roundup/roundup-mailgw.py"
+
+
+2.3 Web Interface
+-----------------
+This software will work through apache or stand-alone.
+
+Stand-alone:
+ 1. Edit server.py at the bottom to set your hostname and a port that is free.
+ 2. "python server.py"
+ 3. Load up the page "/" using the port number you set.
+
+Apache:
+ 1. Make sure roundup.cgi is executable
+ 2. Edit your /etc/httpd/conf/httpd.conf and make sure that the
+ /home/httpd/html/roundup/roundup.cgi script will be treated as a CGI
+ script.
+ 3. Add the following to your /etc/httpd/conf/httpd.conf:
+snip >>>
+RewriteEngine on
+RewriteCond %{HTTP:Authorization} ^(.*)
+RewriteRule ^/roundup/roundup.cgi(.*) /home/httpd/html/roundup/roundup.cgi$1 [e=HTTP_CGI_AUTHORIZATION:%1,t=application/x-httpd-cgi,l]
+<<< snip
+ note: the RewriteRule must be on one line - no breaks
+ 4. Re-start your apache to re-load the config
+ 5. Load up the page "/roundup/roundup.cgi/"
+
+
+3. Usage
+========
+The system is designed to accessed through the command-line, e-mail or web
+interface.
+
+3.1 Command-line
+----------------
+The command-line tool is called "roundup.py" and is used for most low-level
+database manipulations such as:
+ . redefining the list of products ("create" and "retire" commands)
+ . adding users manually, or setting their passwords ("create" and "set")
+ . other stuff - run it with no arguments to get a better description of
+ what it does.
+
+
+3.2 E-mail
+----------
+See the docstring at the start of the roundup-mailgw.py source file.
+
+
+3.3 Web
+-------
+Hopefully, this interface is pretty self-explanatory...
+
+Index views may be modified by the following arguments:
+ :sort - sort by prop name, optionally preceeded with '-'
+ to give descending or nothing for ascending sorting.
+ :group - group by prop name, optionally preceeded with '-' or
+ to sort in descending or nothing for ascending order.
+ :filter - selects which props should be displayed in the filter
+ section. Default is all.
+ :columns - selects the columns that should be displayed.
+ Default is all.
+ propname - selects the values the node properties given by propname
+ must have (very basic search/filter).
+
+
+
+3. Design
+=========
+This software was written according to the specification found at
+ http://software-carpentry.codesourcery.com/entries/second-round/track/Roundup/
+
+... with some modifications. I've marked these in the source with 'XXX'
+comments when I remember to.
+
+In short:
+ Class.find() - may match multiple properties, uses keyword args.
+
+ Class.filter() - isn't in the spec and it's very useful to have at the Class
+ level.
+
+ CGI interface index view specifier layout part - lose the '+' from the
+ sorting arguments (it's a reserved URL character ;). Just made no
+ prefix mean ascending and '-' prefix descending.
+
+ ItemClass - renamed to IssueClass to better match it only having one
+ hypderdb class "issue". Allowing > 1 hyperdb class breaks the
+ "superseder" multilink (since it can only link to one thing, and we'd
+ want bugs to link to support and vice-versa).
+
+ templates - the call="link()" is handled by special-case mechanisms in my
+ top-level CGI handler. In a nutshell, the handler looks for a method on
+ itself called 'index%s' or 'item%s' where %s is a class. Most items
+ pass on to the templating mechanism, but the file class _always_ does
+ downloading. It'll probably stay this way too...
+
+ template - call="link(property)" may be used to link "the current node"
+ (from an index) - the link text is the property specified.
+
+ template - added functions that I found very useful: List, History and
+ Submit.
+
+ template - items must specify the message lists, history, etc. Having them
+ by default was sometimes not wanted.
+
+ template - index view determines its default columns from the template's
+ <property> tags.
+
+ template - menu() and field() look awfully similar now .... ;)
+
+ roundup.py - the command-line tool has a lot more commands at its disposal
+
+
+4. TODO
+=======
+Most of the TODO items are captured in comments in the code. In summary:
+
+in general:
+ . better error handling (nicer messages for users)
+ . possibly revert the entire damn thing to 1.5.2 ... :(
+hyperdb:
+ . transaction support
+roundupdb:
+ . split the file storage into multiple files
+roundup-mailgw:
+ . errors as attachments
+ . snip signatures?
+server:
+ . check the source file timestamps before reloading
+date:
+ . blue Date.__sub__ needs food, badly
+config
+ . default to blank config in distribution and warn appropriately
+roundup_cgi
+ . searching
+ . keep form fields in form on bad submission - only clear it if all ok
+
+
+
+5. Known Bugs
+=============
+
+http://dirk.adroit/roundup/roundup.cgi/issue?%3Acolumns%3Dactivity%2Cstatus%2Ctitle&%3Asort%3Dtitle%2C-activity&%3Agroup%3Dpriority
+
+date:
+ . date subtraction doesn't work correctly "if the dates cross leap years,
+ phases of the moon, ..."
+
+The software still probably has bugs. Please let me know when you find 'em.
+Patches are nice, but there'll probably be a good chance I've changed the
+code (there's not much to it ;) so a good description will be appreciated
+as well.
+
+
+
+6. Author
+=========
+richard@bizarsoftware.com.au
+
+
+7. Thanks
+=========
+Well, Ping, of course ;)
+
+Anthony Baxter, for some good first-release feedback.
+
diff --git a/cgitb.py b/cgitb.py
--- /dev/null
+++ b/cgitb.py
@@ -0,0 +1,113 @@
+import sys, os, types, string, keyword, linecache, tokenize, inspect, pydoc
+
+def breaker():
+ return ('<body bgcolor="#f0f0ff">' +
+ '<font color="#f0f0ff" size="-5"> > </font> ' +
+ '</table>' * 5)
+
+def html(context=5):
+ etype, evalue = sys.exc_type, sys.exc_value
+ if type(etype) is types.ClassType:
+ etype = etype.__name__
+ pyver = 'Python ' + string.split(sys.version)[0] + '<br>' + sys.executable
+ head = pydoc.html.heading(
+ '<font size=+1><strong>%s</strong>: %s</font>'%(str(etype), str(evalue)),
+ '#ffffff', '#aa55cc', pyver)
+
+ head = head + ('<p>A problem occurred while running a Python script. '
+ 'Here is the sequence of function calls leading up to '
+ 'the error, with the most recent (innermost) call first.'
+ 'The exception attributes are:')
+
+ indent = '<tt><small>%s</small> </tt>' % (' ' * 5)
+ traceback = []
+ for frame, file, lnum, func, lines, index in inspect.trace(context):
+ if file is None:
+ link = '<file is None - probably inside <tt>eval</tt> or <tt>exec</tt>>'
+ else:
+ file = os.path.abspath(file)
+ link = '<a href="file:%s">%s</a>' % (file, pydoc.html.escape(file))
+ args, varargs, varkw, locals = inspect.getargvalues(frame)
+ if func == '?':
+ call = ''
+ else:
+ call = 'in <strong>%s</strong>' % func + inspect.formatargvalues(
+ args, varargs, varkw, locals,
+ formatvalue=lambda value: '=' + pydoc.html.repr(value))
+
+ level = '''
+<table width="100%%" bgcolor="#d8bbff" cellspacing=0 cellpadding=2 border=0>
+<tr><td>%s %s</td></tr></table>''' % (link, call)
+
+ if file is None:
+ traceback.append('<p>' + level)
+ continue
+
+ # do a fil inspection
+ names = []
+ def tokeneater(type, token, start, end, line, names=names):
+ if type == tokenize.NAME and token not in keyword.kwlist:
+ if token not in names:
+ names.append(token)
+ if type == tokenize.NEWLINE: raise IndexError
+ def linereader(file=file, lnum=[lnum]):
+ line = linecache.getline(file, lnum[0])
+ lnum[0] = lnum[0] + 1
+ return line
+
+ try:
+ tokenize.tokenize(linereader, tokeneater)
+ except IndexError: pass
+ lvals = []
+ for name in names:
+ if name in frame.f_code.co_varnames:
+ if locals.has_key(name):
+ value = pydoc.html.repr(locals[name])
+ else:
+ value = '<em>undefined</em>'
+ name = '<strong>%s</strong>' % name
+ else:
+ if frame.f_globals.has_key(name):
+ value = pydoc.html.repr(frame.f_globals[name])
+ else:
+ value = '<em>undefined</em>'
+ name = '<em>global</em> <strong>%s</strong>' % name
+ lvals.append('%s = %s' % (name, value))
+ if lvals:
+ lvals = string.join(lvals, ', ')
+ lvals = indent + '''
+<small><font color="#909090">%s</font></small><br>''' % lvals
+ else:
+ lvals = ''
+
+ excerpt = []
+ i = lnum - index
+ for line in lines:
+ number = ' ' * (5-len(str(i))) + str(i)
+ number = '<small><font color="#909090">%s</font></small>' % number
+ line = '<tt>%s %s</tt>' % (number, pydoc.html.preformat(line))
+ if i == lnum:
+ line = '''
+<table width="100%%" bgcolor="#ffccee" cellspacing=0 cellpadding=0 border=0>
+<tr><td>%s</td></tr></table>''' % line
+ excerpt.append('\n' + line)
+ if i == lnum:
+ excerpt.append(lvals)
+ i = i + 1
+ traceback.append('<p>' + level + string.join(excerpt, '\n'))
+
+ traceback.reverse()
+
+ exception = '<p><strong>%s</strong>: %s' % (str(etype), str(evalue))
+ attribs = []
+ if type(evalue) is types.InstanceType:
+ for name in dir(evalue):
+ value = pydoc.html.repr(getattr(evalue, name))
+ attribs.append('<br>%s%s = %s' % (indent, name, value))
+
+ return head + string.join(attribs) + string.join(traceback) + '<p> </p>'
+
+def handler():
+ print breaker()
+ print html()
+
diff --git a/config.py b/config.py
--- /dev/null
+++ b/config.py
@@ -0,0 +1,14 @@
+# This is the directory that the database is going to be stored in
+DATABASE = '/home/httpd/html/roundup/db'
+
+# The email address that mail to roundup should go to
+ISSUE_TRACKER_EMAIL = 'issue_tracker@bizarsoftware.com.au'
+
+# The email address that roundup will complain to if it runs into trouble
+ADMIN_EMAIL = "roundup-admin@bizarsoftware.com.au"
+
+# The SMTP mail host that roundup will use to send mail
+MAILHOST = 'goanna.adroit.net'
+
+# Somewhere for roundup to log stuff internally sent to stdout or stderr
+LOG = '/home/httpd/html/roundup/roundup.log'
diff --git a/date.py b/date.py
--- /dev/null
+++ b/date.py
@@ -0,0 +1,342 @@
+import time, re, calendar
+
+class Date:
+ '''
+ As strings, date-and-time stamps are specified with the date in
+ international standard format (yyyy-mm-dd) joined to the time
+ (hh:mm:ss) by a period ("."). Dates in this form can be easily compared
+ and are fairly readable when printed. An example of a valid stamp is
+ "2000-06-24.13:03:59". We'll call this the "full date format". When
+ Timestamp objects are printed as strings, they appear in the full date
+ format with the time always given in GMT. The full date format is
+ always exactly 19 characters long.
+
+ For user input, some partial forms are also permitted: the whole time
+ or just the seconds may be omitted; and the whole date may be omitted
+ or just the year may be omitted. If the time is given, the time is
+ interpreted in the user's local time zone. The Date constructor takes
+ care of these conversions. In the following examples, suppose that yyyy
+ is the current year, mm is the current month, and dd is the current day
+ of the month; and suppose that the user is on Eastern Standard Time.
+
+ "2000-04-17" means <Date 2000-04-17.00:00:00>
+ "01-25" means <Date yyyy-01-25.00:00:00>
+ "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+ "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+ "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+ "14:25" means <Date yyyy-mm-dd.19:25:00>
+ "8:47:11" means <Date yyyy-mm-dd.13:47:11>
+ "." means "right now"
+
+ The Date class should understand simple date expressions of the form
+ stamp + interval and stamp - interval. When adding or subtracting
+ intervals involving months or years, the components are handled
+ separately. For example, when evaluating "2000-06-25 + 1m 10d", we
+ first add one month to get 2000-07-25, then add 10 days to get
+ 2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40
+ or 41 days).
+
+ Example usage:
+ >>> Date(".")
+ <Date 2000-06-26.00:34:02>
+ >>> _.local(-5)
+ "2000-06-25.19:34:02"
+ >>> Date(". + 2d")
+ <Date 2000-06-28.00:34:02>
+ >>> Date("1997-04-17", -5)
+ <Date 1997-04-17.00:00:00>
+ >>> Date("01-25", -5)
+ <Date 2000-01-25.00:00:00>
+ >>> Date("08-13.22:13", -5)
+ <Date 2000-08-14.03:13:00>
+ >>> Date("14:25", -5)
+ <Date 2000-06-25.19:25:00>
+ '''
+ isDate = 1
+
+ def __init__(self, spec='.', offset=0, set=None):
+ """Construct a date given a specification and a time zone offset.
+
+ 'spec' is a full date or a partial form, with an optional
+ added or subtracted interval.
+ 'offset' is the local time zone offset from GMT in hours.
+ """
+ if set is None:
+ self.set(spec, offset=offset)
+ else:
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second, x, x, x = set
+ self.offset = offset
+
+ def applyInterval(self, interval):
+ ''' Apply the interval to this date
+ '''
+ t = (self.year + interval.year,
+ self.month + interval.month,
+ self.day + interval.day,
+ self.hour + interval.hour,
+ self.minute + interval.minute,
+ self.second + interval.second, 0, 0, 0)
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second, x, x, x = time.gmtime(calendar.timegm(t))
+
+ def __add__(self, other):
+ """Add an interval to this date to produce another date."""
+ t = (self.year + other.sign * other.year,
+ self.month + other.sign * other.month,
+ self.day + other.sign * other.day,
+ self.hour + other.sign * other.hour,
+ self.minute + other.sign * other.minute,
+ self.second + other.sign * other.second, 0, 0, 0)
+ return Date(set = time.gmtime(calendar.timegm(t)))
+
+ # XXX deviates from spec to allow subtraction of dates as well
+ def __sub__(self, other):
+ """ Subtract:
+ 1. an interval from this date to produce another date.
+ 2. a date from this date to produce an interval.
+ """
+ if other.isDate:
+ # TODO this code will fall over laughing if the dates cross
+ # leap years, phases of the moon, ....
+ a = calendar.timegm((self.year, self.month, self.day, self.hour,
+ self.minute, self.second, 0, 0, 0))
+ b = calendar.timegm((other.year, other.month, other.day, other.hour,
+ other.minute, other.second, 0, 0, 0))
+ diff = a - b
+ if diff < 0:
+ sign = -1
+ diff = -diff
+ else:
+ sign = 1
+ S = diff%60
+ M = (diff/60)%60
+ H = (diff/(60*60))%60
+ if H>1: S = 0
+ d = (diff/(24*60*60))%30
+ if d>1: H = S = M = 0
+ m = (diff/(30*24*60*60))%12
+ if m>1: H = S = M = 0
+ y = (diff/(365*24*60*60))
+ if y>1: d = H = S = M = 0
+ return Interval((y, m, d, H, M, S), sign=sign)
+ t = (self.year - other.sign * other.year,
+ self.month - other.sign * other.month,
+ self.day - other.sign * other.day,
+ self.hour - other.sign * other.hour,
+ self.minute - other.sign * other.minute,
+ self.second - other.sign * other.second, 0, 0, 0)
+ return Date(set = time.gmtime(calendar.timegm(t)))
+
+ def __cmp__(self, other):
+ """Compare this date to another date."""
+ for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+ r = cmp(getattr(self, attr), getattr(other, attr))
+ if r: return r
+ return 0
+
+ def __str__(self):
+ """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
+ return time.strftime('%Y-%m-%d.%T', (self.year, self.month,
+ self.day, self.hour, self.minute, self.second, 0, 0, 0))
+
+ def pretty(self):
+ ''' print up the date date using a pretty format...
+ '''
+ return time.strftime('%e %B %Y', (self.year, self.month,
+ self.day, self.hour, self.minute, self.second, 0, 0, 0))
+
+ def set(self, spec, offset=0, date_re=re.compile(r'''
+ (((?P<y>\d\d\d\d)-)?((?P<m>\d\d)-(?P<d>\d\d))?)? # yyyy-mm-dd
+ (?P<n>\.)? # .
+ (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)? # hh:mm:ss
+ (?P<o>.+)? # offset
+ ''', re.VERBOSE)):
+ ''' set the date to the value in spec
+ '''
+ m = date_re.match(spec)
+ if not m:
+ raise ValueError, 'Not a date spec: [[yyyy-]mm-dd].[[h]h:mm[:ss]] [offset]'
+ info = m.groupdict()
+
+ # get the current date/time using the offset
+ y,m,d,H,M,S,x,x,x = time.gmtime(time.time())
+ ts = calendar.timegm((y,m,d,H+offset,M,S,0,0,0))
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second, x, x, x = time.gmtime(ts)
+
+ if info['m'] is not None and info['d'] is not None:
+ self.month = int(info['m'])
+ self.day = int(info['d'])
+ if info['y'] is not None:
+ self.year = int(info['y'])
+ self.hour = self.minute = self.second = 0
+
+ if info['H'] is not None and info['M'] is not None:
+ self.hour = int(info['H'])
+ self.minute = int(info['M'])
+ if info['S'] is not None:
+ self.second = int(info['S'])
+
+ if info['o']:
+ self.applyInterval(Interval(info['o']))
+
+ def __repr__(self):
+ return '<Date %s>'%self.__str__()
+
+ def local(self, offset):
+ """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone."""
+ t = (self.year, self.month, self.day, self.hour + offset, self.minute,
+ self.second, 0, 0, 0)
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second, x, x, x = time.gmtime(calendar.timegm(t))
+
+
+class Interval:
+ '''
+ Date intervals are specified using the suffixes "y", "m", and "d". The
+ suffix "w" (for "week") means 7 days. Time intervals are specified in
+ hh:mm:ss format (the seconds may be omitted, but the hours and minutes
+ may not).
+
+ "3y" means three years
+ "2y 1m" means two years and one month
+ "1m 25d" means one month and 25 days
+ "2w 3d" means two weeks and three days
+ "1d 2:50" means one day, two hours, and 50 minutes
+ "14:00" means 14 hours
+ "0:04:33" means four minutes and 33 seconds
+
+ Example usage:
+ >>> Interval(" 3w 1 d 2:00")
+ <Interval 22d 2:00>
+ >>> Date(". + 2d") - Interval("3w")
+ <Date 2000-06-07.00:34:02>
+ '''
+ isInterval = 1
+
+ def __init__(self, spec, sign=1):
+ """Construct an interval given a specification."""
+ if type(spec) == type(''):
+ self.set(spec)
+ else:
+ self.sign = sign
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second = spec
+
+ def __cmp__(self, other):
+ """Compare this interval to another interval."""
+ for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+ r = cmp(getattr(self, attr), getattr(other, attr))
+ if r: return r
+ return 0
+
+ def __str__(self):
+ """Return this interval as a string."""
+ sign = {1:'+', -1:'-'}[self.sign]
+ l = [sign]
+ if self.year: l.append('%sy'%self.year)
+ if self.month: l.append('%sm'%self.month)
+ if self.day: l.append('%sd'%self.day)
+ if self.second:
+ l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
+ elif self.hour or self.minute:
+ l.append('%d:%02d'%(self.hour, self.minute))
+ return ' '.join(l)
+
+ def set(self, spec, interval_re = re.compile('''
+ \s*
+ (?P<s>[-+])? # + or -
+ \s*
+ ((?P<y>\d+\s*)y)? # year
+ \s*
+ ((?P<m>\d+\s*)m)? # month
+ \s*
+ ((?P<w>\d+\s*)w)? # week
+ \s*
+ ((?P<d>\d+\s*)d)? # day
+ \s*
+ (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)? # time
+ \s*
+ ''', re.VERBOSE)):
+ ''' set the date to the value in spec
+ '''
+ self.year = self.month = self.week = self.day = self.hour = \
+ self.minute = self.second = 0
+ self.sign = 1
+ m = interval_re.match(spec)
+ if not m:
+ raise ValueError, 'Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]'
+
+ info = m.groupdict()
+ for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
+ 'H':'hour', 'M':'minute', 'S':'second'}.items():
+ if info[group] is not None:
+ setattr(self, attr, int(info[group]))
+
+ if self.week:
+ self.day = self.day + self.week*7
+
+ if info['s'] is not None:
+ self.sign = {'+':1, '-':-1}[info['s']]
+
+ def __repr__(self):
+ return '<Interval %s>'%self.__str__()
+
+ def pretty(self, threshold=('d', 5)):
+ ''' print up the date date using one of these nice formats..
+ < 1 minute
+ < 15 minutes
+ < 30 minutes
+ < 1 hour
+ < 12 hours
+ < 1 day
+ otherwise, return None (so a full date may be displayed)
+ '''
+ if self.year or self.month or self.day > 5:
+ return None
+ if self.day > 1:
+ return '%s days'%self.day
+ if self.day == 1 or self.hour > 12:
+ return 'yesterday'
+ if self.hour > 1:
+ return '%s hours'%self.hour
+ if self.hour == 1:
+ if self.minute < 15:
+ return 'an hour'
+ quart = self.minute/15
+ if quart == 2:
+ return '1 1/2 hours'
+ return '1 %s/4 hours'%quart
+ if self.minute < 1:
+ return 'just now'
+ if self.minute == 1:
+ return '1 minute'
+ if self.minute < 15:
+ return '%s minutes'%self.minute
+ quart = self.minute/15
+ if quart == 2:
+ return '1/2 an hour'
+ return '%s/4 hour'%quart
+
+
+def test():
+ intervals = (" 3w 1 d 2:00", " + 2d", "3w")
+ for interval in intervals:
+ print '>>> Interval("%s")'%interval
+ print `Interval(interval)`
+
+ dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
+ "08-13.22:13", "14:25")
+ for date in dates:
+ print '>>> Date("%s")'%date
+ print `Date(date)`
+
+ sums = ((". + 2d", "3w"), (".", " 3w 1 d 2:00"))
+ for date, interval in sums:
+ print '>>> Date("%s") + Interval("%s")'%(date, interval)
+ print `Date(date) + Interval(interval)`
+
+if __name__ == '__main__':
+ test()
+
diff --git a/hyperdb.py b/hyperdb.py
--- /dev/null
+++ b/hyperdb.py
@@ -0,0 +1,918 @@
+import bsddb, os, cPickle, re, string
+
+import date
+#
+# Types
+#
+class BaseType:
+ isStringType = 0
+ isDateType = 0
+ isIntervalType = 0
+ isLinkType = 0
+ isMultilinkType = 0
+
+class String(BaseType):
+ def __init__(self):
+ """An object designating a String property."""
+ pass
+ def __repr__(self):
+ return '<%s>'%self.__class__
+ isStringType = 1
+
+class Date(BaseType, String):
+ isDateType = 1
+
+class Interval(BaseType, String):
+ isIntervalType = 1
+
+class Link(BaseType):
+ def __init__(self, classname):
+ """An object designating a Link property that links to
+ nodes in a specified class."""
+ self.classname = classname
+ def __repr__(self):
+ return '<%s to "%s">'%(self.__class__, self.classname)
+ isLinkType = 1
+
+class Multilink(BaseType, Link):
+ """An object designating a Multilink property that links
+ to nodes in a specified class.
+ """
+ isMultilinkType = 1
+
+class DatabaseError(ValueError):
+ pass
+
+#
+# Now the database
+#
+RETIRED_FLAG = '__hyperdb_retired'
+class Database:
+ """A database for storing records containing flexible data types."""
+
+ def __init__(self, storagelocator, journaltag=None):
+ """Open a hyperdatabase given a specifier to some storage.
+
+ The meaning of 'storagelocator' depends on the particular
+ implementation of the hyperdatabase. It could be a file name,
+ a directory path, a socket descriptor for a connection to a
+ database over the network, etc.
+
+ The 'journaltag' is a token that will be attached to the journal
+ entries for any edits done on the database. If 'journaltag' is
+ None, the database is opened in read-only mode: the Class.create(),
+ Class.set(), and Class.retire() methods are disabled.
+ """
+ self.dir, self.journaltag = storagelocator, journaltag
+ self.classes = {}
+
+ #
+ # Classes
+ #
+ def __getattr__(self, classname):
+ """A convenient way of calling self.getclass(classname)."""
+ return self.classes[classname]
+
+ def addclass(self, cl):
+ cn = cl.classname
+ if self.classes.has_key(cn):
+ raise ValueError, cn
+ self.classes[cn] = cl
+
+ def getclasses(self):
+ """Return a list of the names of all existing classes."""
+ l = self.classes.keys()
+ l.sort()
+ return l
+
+ def getclass(self, classname):
+ """Get the Class object representing a particular class.
+
+ If 'classname' is not a valid class name, a KeyError is raised.
+ """
+ return self.classes[classname]
+
+ #
+ # Class DBs
+ #
+ def clear(self):
+ for cn in self.classes.keys():
+ db = os.path.join(self.dir, 'nodes.%s'%cn)
+ bsddb.btopen(db, 'n')
+ db = os.path.join(self.dir, 'journals.%s'%cn)
+ bsddb.btopen(db, 'n')
+
+ def getclassdb(self, classname, mode='r'):
+ ''' grab a connection to the class db that will be used for
+ multiple actions
+ '''
+ path = os.path.join(os.getcwd(), self.dir, 'nodes.%s'%classname)
+ return bsddb.btopen(path, mode)
+
+ def addnode(self, classname, nodeid, node):
+ ''' add the specified node to its class's db
+ '''
+ db = self.getclassdb(classname, 'c')
+ db[nodeid] = cPickle.dumps(node, 1)
+ db.close()
+ setnode = addnode
+
+ def getnode(self, classname, nodeid, cldb=None):
+ ''' add the specified node to its class's db
+ '''
+ db = cldb or self.getclassdb(classname)
+ if not db.has_key(nodeid):
+ raise IndexError, nodeid
+ res = cPickle.loads(db[nodeid])
+ if not cldb: db.close()
+ return res
+
+ def hasnode(self, classname, nodeid, cldb=None):
+ ''' add the specified node to its class's db
+ '''
+ db = cldb or self.getclassdb(classname)
+ res = db.has_key(nodeid)
+ if not cldb: db.close()
+ return res
+
+ def countnodes(self, classname, cldb=None):
+ db = cldb or self.getclassdb(classname)
+ return len(db.keys())
+ if not cldb: db.close()
+ return res
+
+ def getnodeids(self, classname, cldb=None):
+ db = cldb or self.getclassdb(classname)
+ res = db.keys()
+ if not cldb: db.close()
+ return res
+
+ #
+ # Journal
+ #
+ def addjournal(self, classname, nodeid, action, params):
+ ''' Journal the Action
+ 'action' may be:
+
+ 'create' or 'set' -- 'params' is a dictionary of property values
+ 'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
+ 'retire' -- 'params' is None
+ '''
+ entry = (nodeid, date.Date(), self.journaltag, action, params)
+ db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'c')
+ if db.has_key(nodeid):
+ s = db[nodeid]
+ l = cPickle.loads(db[nodeid])
+ l.append(entry)
+ else:
+ l = [entry]
+ db[nodeid] = cPickle.dumps(l)
+ db.close()
+
+ def getjournal(self, classname, nodeid):
+ ''' get the journal for id
+ '''
+ db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'r')
+ res = cPickle.loads(db[nodeid])
+ db.close()
+ return res
+
+ def close(self):
+ ''' Close the Database - we must release the circular refs so that
+ we can be del'ed and the underlying bsddb connections closed
+ cleanly.
+ '''
+ self.classes = None
+
+
+ #
+ # Basic transaction support
+ #
+ # TODO: well, write these methods (and then use them in other code)
+ def register_action(self):
+ ''' Register an action to the transaction undo log
+ '''
+
+ def commit(self):
+ ''' Commit the current transaction, start a new one
+ '''
+
+ def rollback(self):
+ ''' Reverse all actions from the current transaction
+ '''
+
+
+class Class:
+ """The handle to a particular class of nodes in a hyperdatabase."""
+
+ def __init__(self, db, classname, **properties):
+ """Create a new class with a given name and property specification.
+
+ 'classname' must not collide with the name of an existing class,
+ or a ValueError is raised. The keyword arguments in 'properties'
+ must map names to property objects, or a TypeError is raised.
+ """
+ self.classname = classname
+ self.properties = properties
+ self.db = db
+ self.key = ''
+
+ # do the db-related init stuff
+ db.addclass(self)
+
+ # Editing nodes:
+
+ def create(self, **propvalues):
+ """Create a new node of this class and return its id.
+
+ The keyword arguments in 'propvalues' map property names to values.
+
+ The values of arguments must be acceptable for the types of their
+ corresponding properties or a TypeError is raised.
+
+ If this class has a key property, it must be present and its value
+ must not collide with other key strings or a ValueError is raised.
+
+ Any other properties on this class that are missing from the
+ 'propvalues' dictionary are set to None.
+
+ If an id in a link or multilink property does not refer to a valid
+ node, an IndexError is raised.
+ """
+ if self.db.journaltag is None:
+ raise DatabaseError, 'Database open read-only'
+ newid = str(self.count() + 1)
+
+ # validate propvalues
+ num_re = re.compile('^\d+$')
+ for key, value in propvalues.items():
+ if key == self.key:
+ try:
+ self.lookup(value)
+ except KeyError:
+ pass
+ else:
+ raise ValueError, 'node with key "%s" exists'%value
+
+ prop = self.properties[key]
+
+ if prop.isLinkType:
+ value = str(value)
+ link_class = self.properties[key].classname
+ if not num_re.match(value):
+ try:
+ value = self.db.classes[link_class].lookup(value)
+ except:
+ raise ValueError, 'new property "%s": %s not a %s'%(
+ key, value, self.properties[key].classname)
+ propvalues[key] = value
+ if not self.db.hasnode(link_class, value):
+ raise ValueError, '%s has no node %s'%(link_class, value)
+
+ # register the link with the newly linked node
+ self.db.addjournal(link_class, value, 'link',
+ (self.classname, newid, key))
+
+ elif prop.isMultilinkType:
+ if type(value) != type([]):
+ raise TypeError, 'new property "%s" not a list of ids'%key
+ link_class = self.properties[key].classname
+ l = []
+ for entry in map(str, value):
+ if not num_re.match(entry):
+ try:
+ entry = self.db.classes[link_class].lookup(entry)
+ except:
+ raise ValueError, 'new property "%s": %s not a %s'%(
+ key, entry, self.properties[key].classname)
+ l.append(entry)
+ value = l
+ propvalues[key] = value
+
+ # handle additions
+ for id in value:
+ if not self.db.hasnode(link_class, id):
+ raise ValueError, '%s has no node %s'%(link_class, id)
+ # register the link with the newly linked node
+ self.db.addjournal(link_class, id, 'link',
+ (self.classname, newid, key))
+
+ elif prop.isStringType:
+ if type(value) != type(''):
+ raise TypeError, 'new property "%s" not a string'%key
+
+ elif prop.isDateType:
+ if not hasattr(value, 'isDate'):
+ raise TypeError, 'new property "%s" not a Date'% key
+
+ elif prop.isIntervalType:
+ if not hasattr(value, 'isInterval'):
+ raise TypeError, 'new property "%s" not an Interval'% key
+
+ for key,prop in self.properties.items():
+ if propvalues.has_key(str(key)):
+ continue
+ if prop.isMultilinkType:
+ propvalues[key] = []
+ else:
+ propvalues[key] = None
+
+ # done
+ self.db.addnode(self.classname, newid, propvalues)
+ self.db.addjournal(self.classname, newid, 'create', propvalues)
+ return newid
+
+ def get(self, nodeid, propname):
+ """Get the value of a property on an existing node of this class.
+
+ 'nodeid' must be the id of an existing node of this class or an
+ IndexError is raised. 'propname' must be the name of a property
+ of this class or a KeyError is raised.
+ """
+ d = self.db.getnode(self.classname, str(nodeid))
+ return d[propname]
+
+ # XXX not in spec
+ def getnode(self, nodeid):
+ ''' Return a convenience wrapper for the node
+ '''
+ return Node(self, nodeid)
+
+ def set(self, nodeid, **propvalues):
+ """Modify a property on an existing node of this class.
+
+ 'nodeid' must be the id of an existing node of this class or an
+ IndexError is raised.
+
+ Each key in 'propvalues' must be the name of a property of this
+ class or a KeyError is raised.
+
+ All values in 'propvalues' must be acceptable types for their
+ corresponding properties or a TypeError is raised.
+
+ If the value of the key property is set, it must not collide with
+ other key strings or a ValueError is raised.
+
+ If the value of a Link or Multilink property contains an invalid
+ node id, a ValueError is raised.
+ """
+ if not propvalues:
+ return
+ if self.db.journaltag is None:
+ raise DatabaseError, 'Database open read-only'
+ nodeid = str(nodeid)
+ node = self.db.getnode(self.classname, nodeid)
+ if node.has_key(RETIRED_FLAG):
+ raise IndexError
+ num_re = re.compile('^\d+$')
+ for key, value in propvalues.items():
+ if not node.has_key(key):
+ raise KeyError, key
+
+ if key == self.key:
+ try:
+ self.lookup(value)
+ except KeyError:
+ pass
+ else:
+ raise ValueError, 'node with key "%s" exists'%value
+
+ prop = self.properties[key]
+
+ if prop.isLinkType:
+ value = str(value)
+ link_class = self.properties[key].classname
+ if not num_re.match(value):
+ try:
+ value = self.db.classes[link_class].lookup(value)
+ except:
+ raise ValueError, 'new property "%s": %s not a %s'%(
+ key, value, self.properties[key].classname)
+
+ if not self.db.hasnode(link_class, value):
+ raise ValueError, '%s has no node %s'%(link_class, value)
+
+ # register the unlink with the old linked node
+ if node[key] is not None:
+ self.db.addjournal(link_class, node[key], 'unlink',
+ (self.classname, nodeid, key))
+
+ # register the link with the newly linked node
+ if value is not None:
+ self.db.addjournal(link_class, value, 'link',
+ (self.classname, nodeid, key))
+
+ elif prop.isMultilinkType:
+ if type(value) != type([]):
+ raise TypeError, 'new property "%s" not a list of ids'%key
+ link_class = self.properties[key].classname
+ l = []
+ for entry in map(str, value):
+ if not num_re.match(entry):
+ try:
+ entry = self.db.classes[link_class].lookup(entry)
+ except:
+ raise ValueError, 'new property "%s": %s not a %s'%(
+ key, entry, self.properties[key].classname)
+ l.append(entry)
+ value = l
+ propvalues[key] = value
+
+ #handle removals
+ l = node[key]
+ for id in l[:]:
+ if id in value:
+ continue
+ # register the unlink with the old linked node
+ self.db.addjournal(link_class, id, 'unlink',
+ (self.classname, nodeid, key))
+ l.remove(id)
+
+ # handle additions
+ for id in value:
+ if not self.db.hasnode(link_class, id):
+ raise ValueError, '%s has no node %s'%(link_class, id)
+ if id in l:
+ continue
+ # register the link with the newly linked node
+ self.db.addjournal(link_class, id, 'link',
+ (self.classname, nodeid, key))
+ l.append(id)
+
+ elif prop.isStringType:
+ if value is not None and type(value) != type(''):
+ raise TypeError, 'new property "%s" not a string'%key
+
+ elif prop.isDateType:
+ if not hasattr(value, 'isDate'):
+ raise TypeError, 'new property "%s" not a Date'% key
+
+ elif prop.isIntervalType:
+ if not hasattr(value, 'isInterval'):
+ raise TypeError, 'new property "%s" not an Interval'% key
+
+ node[key] = value
+
+ self.db.setnode(self.classname, nodeid, node)
+ self.db.addjournal(self.classname, nodeid, 'set', propvalues)
+
+ def retire(self, nodeid):
+ """Retire a node.
+
+ The properties on the node remain available from the get() method,
+ and the node's id is never reused.
+
+ Retired nodes are not returned by the find(), list(), or lookup()
+ methods, and other nodes may reuse the values of their key properties.
+ """
+ nodeid = str(nodeid)
+ if self.db.journaltag is None:
+ raise DatabaseError, 'Database open read-only'
+ node = self.db.getnode(self.classname, nodeid)
+ node[RETIRED_FLAG] = 1
+ self.db.setnode(self.classname, nodeid, node)
+ self.db.addjournal(self.classname, nodeid, 'retired', None)
+
+ def history(self, nodeid):
+ """Retrieve the journal of edits on a particular node.
+
+ 'nodeid' must be the id of an existing node of this class or an
+ IndexError is raised.
+
+ The returned list contains tuples of the form
+
+ (date, tag, action, params)
+
+ 'date' is a Timestamp object specifying the time of the change and
+ 'tag' is the journaltag specified when the database was opened.
+ """
+ return self.db.getjournal(self.classname, nodeid)
+
+ # Locating nodes:
+
+ def setkey(self, propname):
+ """Select a String property of this class to be the key property.
+
+ 'propname' must be the name of a String property of this class or
+ None, or a TypeError is raised. The values of the key property on
+ all existing nodes must be unique or a ValueError is raised.
+ """
+ self.key = propname
+
+ def getkey(self):
+ """Return the name of the key property for this class or None."""
+ return self.key
+
+ # TODO: set up a separate index db file for this? profile?
+ def lookup(self, keyvalue):
+ """Locate a particular node by its key property and return its id.
+
+ If this class has no key property, a TypeError is raised. If the
+ 'keyvalue' matches one of the values for the key property among
+ the nodes in this class, the matching node's id is returned;
+ otherwise a KeyError is raised.
+ """
+ cldb = self.db.getclassdb(self.classname)
+ for nodeid in self.db.getnodeids(self.classname, cldb):
+ node = self.db.getnode(self.classname, nodeid, cldb)
+ if node.has_key(RETIRED_FLAG):
+ continue
+ if node[self.key] == keyvalue:
+ return nodeid
+ cldb.close()
+ raise KeyError, keyvalue
+
+ # XXX: change from spec - allows multiple props to match
+ def find(self, **propspec):
+ """Get the ids of nodes in this class which link to a given node.
+
+ 'propspec' consists of keyword args propname=nodeid
+ 'propname' must be the name of a property in this class, or a
+ KeyError is raised. That property must be a Link or Multilink
+ property, or a TypeError is raised.
+
+ 'nodeid' must be the id of an existing node in the class linked
+ to by the given property, or an IndexError is raised.
+ """
+ propspec = propspec.items()
+ for propname, nodeid in propspec:
+ nodeid = str(nodeid)
+ # check the prop is OK
+ prop = self.properties[propname]
+ if not prop.isLinkType and not prop.isMultilinkType:
+ raise TypeError, "'%s' not a Link/Multilink property"%propname
+ if not self.db.hasnode(prop.classname, nodeid):
+ raise ValueError, '%s has no node %s'%(link_class, nodeid)
+
+ # ok, now do the find
+ cldb = self.db.getclassdb(self.classname)
+ l = []
+ for id in self.db.getnodeids(self.classname, cldb):
+ node = self.db.getnode(self.classname, id, cldb)
+ if node.has_key(RETIRED_FLAG):
+ continue
+ for propname, nodeid in propspec:
+ nodeid = str(nodeid)
+ property = node[propname]
+ if prop.isLinkType and nodeid == property:
+ l.append(id)
+ elif prop.isMultilinkType and nodeid in property:
+ l.append(id)
+ cldb.close()
+ return l
+
+ def stringFind(self, **requirements):
+ """Locate a particular node by matching a set of its String properties.
+
+ If the property is not a String property, a TypeError is raised.
+
+ The return is a list of the id of all nodes that match.
+ """
+ for propname in requirements.keys():
+ prop = self.properties[propname]
+ if not prop.isStringType:
+ raise TypeError, "'%s' not a String property"%propname
+ l = []
+ cldb = self.db.getclassdb(self.classname)
+ for nodeid in self.db.getnodeids(self.classname, cldb):
+ node = self.db.getnode(self.classname, nodeid, cldb)
+ if node.has_key(RETIRED_FLAG):
+ continue
+ for key, value in requirements.items():
+ if node[key] != value:
+ break
+ else:
+ l.append(nodeid)
+ cldb.close()
+ return l
+
+ def list(self):
+ """Return a list of the ids of the active nodes in this class."""
+ l = []
+ cn = self.classname
+ cldb = self.db.getclassdb(cn)
+ for nodeid in self.db.getnodeids(cn, cldb):
+ node = self.db.getnode(cn, nodeid, cldb)
+ if node.has_key(RETIRED_FLAG):
+ continue
+ l.append(nodeid)
+ l.sort()
+ cldb.close()
+ return l
+
+ # XXX not in spec
+ def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
+ ''' Return a list of the ids of the active nodes in this class that
+ match the 'filter' spec, sorted by the group spec and then the
+ sort spec
+ '''
+ cn = self.classname
+
+ # optimise filterspec
+ l = []
+ props = self.getprops()
+ for k, v in filterspec.items():
+ propclass = props[k]
+ if propclass.isLinkType:
+ if type(v) is not type([]):
+ v = [v]
+ # replace key values with node ids
+ u = []
+ link_class = self.db.classes[propclass.classname]
+ for entry in v:
+ if not num_re.match(entry):
+ try:
+ entry = link_class.lookup(entry)
+ except:
+ raise ValueError, 'new property "%s": %s not a %s'%(
+ key, entry, self.properties[key].classname)
+ u.append(entry)
+
+ l.append((0, k, u))
+ elif propclass.isMultilinkType:
+ if type(v) is not type([]):
+ v = [v]
+ # replace key values with node ids
+ u = []
+ link_class = self.db.classes[propclass.classname]
+ for entry in v:
+ if not num_re.match(entry):
+ try:
+ entry = link_class.lookup(entry)
+ except:
+ raise ValueError, 'new property "%s": %s not a %s'%(
+ key, entry, self.properties[key].classname)
+ u.append(entry)
+ l.append((1, k, u))
+ elif propclass.isStringType:
+ v = v[0]
+ if '*' in v or '?' in v:
+ # simple glob searching
+ v = v.replace(v, '?', '.')
+ v = v.replace(v, '*', '.*?')
+ l.append((2, k, v))
+ elif v[0] == '^':
+ # start-anchored
+ if v[-1] == '$':
+ # _and_ end-anchored
+ l.append((6, k, v[1:-1]))
+ l.append((3, k, v[1:]))
+ elif v[-1] == '$':
+ # end-anchored
+ l.append((4, k, v[:-1]))
+ else:
+ # substring
+ l.append((5, k, v))
+ else:
+ l.append((6, k, v))
+ filterspec = l
+
+ # now, find all the nodes that are active and pass filtering
+ l = []
+ cldb = self.db.getclassdb(cn)
+ for nodeid in self.db.getnodeids(cn, cldb):
+ node = self.db.getnode(cn, nodeid, cldb)
+ if node.has_key(RETIRED_FLAG):
+ continue
+ # apply filter
+ for t, k, v in filterspec:
+ if t == 0 and node[k] not in v:
+ # link - if this node'd property doesn't appear in the
+ # filterspec's nodeid list, skip it
+ break
+ elif t == 1:
+ # multilink - if any of the nodeids required by the
+ # filterspec aren't in this node's property, then skip
+ # it
+ for value in v:
+ if value not in node[k]:
+ break
+ else:
+ continue
+ break
+ elif t == 2 and not v.match(node[k]):
+ # RE search
+ break
+ elif t == 3 and node[k][:len(v)] != v:
+ # start anchored
+ break
+ elif t == 4 and node[k][-len(v):] != v:
+ # end anchored
+ break
+ elif t == 5 and node[k].find(v) == -1:
+ # substring search
+ break
+ elif t == 6 and node[k] != v:
+ # straight value comparison for the other types
+ break
+ else:
+ l.append((nodeid, node))
+ l.sort()
+ cldb.close()
+
+ # optimise sort
+ m = []
+ for entry in sort:
+ if entry[0] != '-':
+ m.append(('+', entry))
+ else:
+ m.append((entry[0], entry[1:]))
+ sort = m
+
+ # optimise group
+ m = []
+ for entry in group:
+ if entry[0] != '-':
+ m.append(('+', entry))
+ else:
+ m.append((entry[0], entry[1:]))
+ group = m
+
+ # now, sort the result
+ def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
+ db = self.db, cl=self):
+ a_id, an = a
+ b_id, bn = b
+ for list in group, sort:
+ for dir, prop in list:
+ # handle the properties that might be "faked"
+ if not an.has_key(prop):
+ an[prop] = cl.get(a_id, prop)
+ av = an[prop]
+ if not bn.has_key(prop):
+ bn[prop] = cl.get(b_id, prop)
+ bv = bn[prop]
+
+ # sorting is class-specific
+ propclass = properties[prop]
+
+ # String and Date values are sorted in the natural way
+ if propclass.isStringType:
+ # make sure that case doesn't get involved
+ if av[0] in string.uppercase:
+ av = an[prop] = av.lower()
+ if bv[0] in string.uppercase:
+ bv = bn[prop] = bv.lower()
+ if propclass.isStringType or propclass.isDateType:
+ if dir == '+':
+ r = cmp(av, bv)
+ if r != 0: return r
+ elif dir == '-':
+ r = cmp(bv, av)
+ if r != 0: return r
+
+ # 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.
+ elif propclass.isLinkType:
+ link = db.classes[propclass.classname]
+ if link.getprops().has_key('order'):
+ if dir == '+':
+ r = cmp(link.get(av, 'order'),
+ link.get(bv, 'order'))
+ if r != 0: return r
+ elif dir == '-':
+ r = cmp(link.get(bv, 'order'),
+ link.get(av, 'order'))
+ if r != 0: return r
+ elif link.getkey():
+ key = link.getkey()
+ if dir == '+':
+ r = cmp(link.get(av, key), link.get(bv, key))
+ if r != 0: return r
+ elif dir == '-':
+ r = cmp(link.get(bv, key), link.get(av, key))
+ if r != 0: return r
+ else:
+ if dir == '+':
+ r = cmp(av, bv)
+ if r != 0: return r
+ elif dir == '-':
+ r = cmp(bv, av)
+ if r != 0: return r
+
+ # Multilink properties are sorted according to how many
+ # links are present.
+ elif propclass.isMultilinkType:
+ if dir == '+':
+ r = cmp(len(av), len(bv))
+ if r != 0: return r
+ elif dir == '-':
+ r = cmp(len(bv), len(av))
+ if r != 0: return r
+ return cmp(a[0], b[0])
+ l.sort(sortfun)
+ return [i[0] for i in l]
+
+ def count(self):
+ """Get the number of nodes in this class.
+
+ If the returned integer is 'numnodes', the ids of all the nodes
+ in this class run from 1 to numnodes, and numnodes+1 will be the
+ id of the next node to be created in this class.
+ """
+ return self.db.countnodes(self.classname)
+
+ # Manipulating properties:
+
+ def getprops(self):
+ """Return a dictionary mapping property names to property objects."""
+ return self.properties
+
+ def addprop(self, **properties):
+ """Add properties to this class.
+
+ The keyword arguments in 'properties' must map names to property
+ objects, or a TypeError is raised. None of the keys in 'properties'
+ may collide with the names of existing properties, or a ValueError
+ is raised before any properties have been added.
+ """
+ for key in properties.keys():
+ if self.properties.has_key(key):
+ raise ValueError, key
+ self.properties.update(properties)
+
+
+# XXX not in spec
+class Node:
+ ''' A convenience wrapper for the given node
+ '''
+ def __init__(self, cl, nodeid):
+ self.__dict__['cl'] = cl
+ self.__dict__['nodeid'] = nodeid
+ def keys(self):
+ return self.cl.getprops().keys()
+ def has_key(self, name):
+ return self.cl.getprops().has_key(name)
+ def __getattr__(self, name):
+ if self.__dict__.has_key(name):
+ return self.__dict__['name']
+ try:
+ return self.cl.get(self.nodeid, name)
+ except KeyError, value:
+ raise AttributeError, str(value)
+ def __getitem__(self, name):
+ return self.cl.get(self.nodeid, name)
+ def __setattr__(self, name, value):
+ try:
+ return self.cl.set(self.nodeid, **{name: value})
+ except KeyError, value:
+ raise AttributeError, str(value)
+ def __setitem__(self, name, value):
+ self.cl.set(self.nodeid, **{name: value})
+ def history(self):
+ return self.cl.history(self.nodeid)
+ def retire(self):
+ return self.cl.retire(self.nodeid)
+
+
+def Choice(name, *options):
+ cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
+ for i in range(len(options)):
+ cl.create(name=option[i], order=i)
+ return hyperdb.Link(name)
+
+
+if __name__ == '__main__':
+ import pprint
+ db = Database("test_db", "richard")
+ status = Class(db, "status", name=String())
+ status.setkey("name")
+ print db.status.create(name="unread")
+ print db.status.create(name="in-progress")
+ print db.status.create(name="testing")
+ print db.status.create(name="resolved")
+ print db.status.count()
+ print db.status.list()
+ print db.status.lookup("in-progress")
+ db.status.retire(3)
+ print db.status.list()
+ issue = Class(db, "issue", title=String(), status=Link("status"))
+ db.issue.create(title="spam", status=1)
+ db.issue.create(title="eggs", status=2)
+ db.issue.create(title="ham", status=4)
+ db.issue.create(title="arguments", status=2)
+ db.issue.create(title="abuse", status=1)
+ user = Class(db, "user", username=String(), password=String())
+ user.setkey("username")
+ db.issue.addprop(fixer=Link("user"))
+ print db.issue.getprops()
+#{"title": <hyperdb.String>, "status": <hyperdb.Link to "status">,
+#"user": <hyperdb.Link to "user">}
+ db.issue.set(5, status=2)
+ print db.issue.get(5, "status")
+ print db.status.get(2, "name")
+ print db.issue.get(5, "title")
+ print db.issue.find(status = db.status.lookup("in-progress"))
+ print db.issue.history(5)
+# [(<Date 2000-06-28.19:09:43>, "ping", "create", {"title": "abuse", "status": 1}),
+# (<Date 2000-06-28.19:11:04>, "ping", "set", {"status": 2})]
+ print db.status.history(1)
+# [(<Date 2000-06-28.19:09:43>, "ping", "link", ("issue", 5, "status")),
+# (<Date 2000-06-28.19:11:04>, "ping", "unlink", ("issue", 5, "status"))]
+ print db.status.history(2)
+# [(<Date 2000-06-28.19:11:04>, "ping", "link", ("issue", 5, "status"))]
+
+ # TODO: set up some filter tests
+
diff --git a/roundup-mailgw.py b/roundup-mailgw.py
--- /dev/null
+++ b/roundup-mailgw.py
@@ -0,0 +1,273 @@
+#! /usr/bin/python
+'''
+Incoming messages are examined for multiple parts. In a multipart/mixed
+message or part, each subpart is extracted and examined. In a
+multipart/alternative message or part, we look for a text/plain subpart and
+ignore the other parts. The text/plain subparts are assembled to form the
+textual body of the message, to be stored in the file associated with a
+"msg" class node. Any parts of other types are each stored in separate
+files and given "file" class nodes that are linked to the "msg" node.
+
+The "summary" property on message nodes is taken from the first non-quoting
+section in the message body. The message body is divided into sections by
+blank lines. Sections where the second and all subsequent lines begin with
+a ">" or "|" character are considered "quoting sections". The first line of
+the first non-quoting section becomes the summary of the message.
+
+All of the addresses in the To: and Cc: headers of the incoming message are
+looked up among the user nodes, and the corresponding users are placed in
+the "recipients" property on the new "msg" node. The address in the From:
+header similarly determines the "author" property of the new "msg"
+node. The default handling for addresses that don't have corresponding
+users is to create new users with no passwords and a username equal to the
+address. (The web interface does not permit logins for users with no
+passwords.) If we prefer to reject mail from outside sources, we can simply
+register an auditor on the "user" class that prevents the creation of user
+nodes with no passwords.
+
+The subject line of the incoming message is examined to determine whether
+the message is an attempt to create a new item or to discuss an existing
+item. A designator enclosed in square brackets is sought as the first thing
+on the subject line (after skipping any "Fwd:" or "Re:" prefixes).
+
+If an item designator (class name and id number) is found there, the newly
+created "msg" node is added to the "messages" property for that item, and
+any new "file" nodes are added to the "files" property for the item.
+
+If just an item class name is found there, we attempt to create a new item
+of that class with its "messages" property initialized to contain the new
+"msg" node and its "files" property initialized to contain any new "file"
+nodes.
+
+Both cases may trigger detectors (in the first case we are calling the
+set() method to add the message to the item's spool; in the second case we
+are calling the create() method to create a new node). If an auditor raises
+an exception, the original message is bounced back to the sender with the
+explanatory message given in the exception.
+'''
+
+import sys
+if int(sys.version[0]) < 2:
+ print "Roundup requires Python 2.0 or newer."
+ sys.exit(0)
+
+import string, re, os, mimetools, StringIO, smtplib, socket, binascii, quopri
+import config, date, roundupdb
+
+def getPart(fp, boundary):
+ line = ''
+ s = StringIO.StringIO()
+ while 1:
+ line_n = fp.readline()
+ if not line_n:
+ break
+ line = line_n.strip()
+ if line == '--'+boundary+'--':
+ break
+ if line == '--'+boundary:
+ break
+ s.write(line_n)
+ if not s.getvalue().strip():
+ return None
+ return s
+
+subject_re = re.compile(r'(\[?(fwd|re):\s*)*'
+ r'(\[(?P<classname>[^\d]+)(?P<nodeid>\d+)?\])'
+ r'(?P<title>[^\[]+)(\[(?P<args>.+?)\])?', re.I)
+
+def roundup_mail(db, fp):
+ # ok, figure the subject, author, recipients and content-type
+ message = mimetools.Message(fp)
+ try:
+ handle_message(db, message)
+ except:
+ # send an email to the people who missed out
+ sendto = [message.getaddrlist('from')[0][1]]
+ m = ['Subject: failed issue tracker submission']
+ m.append('')
+ # TODO as attachments?
+ m.append('---- traceback of failure ----')
+ return
+ s = StringIO.StringIO()
+ import traceback
+ traceback.print_exc(None, s)
+ m.append(s.getvalue())
+ m.append('---- failed message follows ----')
+ try:
+ fp.seek(0)
+ except:
+ pass
+ m.append(fp.read())
+ try:
+ smtp = smtplib.SMTP(config.MAILHOST)
+ smtp.sendmail(config.ADMIN_EMAIL, sendto, '\n'.join(m))
+ except socket.error, value:
+ return "Couldn't send confirmation email: mailhost %s"%value
+ except smtplib.SMTPException, value:
+ return "Couldn't send confirmation email: %s"%value
+
+def handle_message(db, message):
+ # handle the subject line
+ m = subject_re.match(message.getheader('subject'))
+ if not m:
+ raise ValueError, 'No [designator] found in subject "%s"'
+ classname = m.group('classname')
+ nodeid = m.group('nodeid')
+ title = m.group('title').strip()
+ subject_args = m.group('args')
+ cl = db.getclass(classname)
+ properties = cl.getprops()
+ props = {}
+ args = m.group('args')
+ if args:
+ for prop in string.split(m.group('args'), ';'):
+ try:
+ key, value = prop.split('=')
+ except ValueError, message:
+ raise ValueError, 'Args list not of form [arg=value,value,...;arg=value,value,value..] (specific exception message was "%s")'%message
+ type = properties[key]
+ if type.isStringType:
+ props[key] = value
+ elif type.isDateType:
+ props[key] = date.Date(value)
+ elif type.isIntervalType:
+ props[key] = date.Interval(value)
+ elif type.isLinkType:
+ props[key] = value
+ elif type.isMultilinkType:
+ props[key] = value.split(',')
+
+ # handle the users
+ author = db.uidFromAddress(message.getaddrlist('from')[0])
+ recipients = []
+ for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
+ if recipient[1].strip().lower() == config.ISSUE_TRACKER_EMAIL:
+ continue
+ recipients.append(db.uidFromAddress(recipient))
+
+ # now handle the body - find the message
+ content_type = message.gettype()
+ attachments = []
+ if content_type == 'multipart/mixed':
+ boundary = message.getparam('boundary')
+ # skip over the intro to the first boundary
+ part = getPart(message.fp, boundary)
+ content = None
+ while 1:
+ # get the next part
+ part = getPart(message.fp, boundary)
+ if part is None:
+ break
+ # parse it
+ part.seek(0)
+ submessage = mimetools.Message(part)
+ subtype = submessage.gettype()
+ if subtype == 'text/plain' and not content:
+ # this one's our content
+ content = part.read()
+ elif subtype == 'message/rfc822':
+ i = part.tell()
+ subsubmess = mimetools.Message(part)
+ name = subsubmess.getheader('subject')
+ part.seek(i)
+ attachments.append((name, 'message/rfc822', part.read()))
+ else:
+ # try name on Content-Type
+ name = submessage.getparam('name')
+ # this is just an attachment
+ data = part.read()
+ encoding = submessage.getencoding()
+ if encoding == 'base64':
+ data = binascii.a2b_base64(data)
+ elif encoding == 'quoted-printable':
+ data = quopri.decode(data)
+ elif encoding == 'uuencoded':
+ data = binascii.a2b_uu(data)
+ attachments.append((name, submessage.gettype(), data))
+ if content is None:
+ raise ValueError, 'No text/plain part found'
+
+ elif content_type[:10] == 'multipart/':
+ boundary = message.getparam('boundary')
+ # skip over the intro to the first boundary
+ getPart(message.fp, boundary)
+ content = None
+ while 1:
+ # get the next part
+ part = getPart(message.fp, boundary)
+ if part is None:
+ break
+ # parse it
+ part.seek(0)
+ submessage = mimetools.Message(part)
+ if submessage.gettype() == 'text/plain' and not content:
+ # this one's our content
+ content = part.read()
+ if content is None:
+ raise ValueError, 'No text/plain part found'
+
+ elif content_type != 'text/plain':
+ raise ValueError, 'No text/plain part found'
+
+ else:
+ content = message.fp.read()
+
+ # extract out the summary from the message
+ summary = []
+ for line in content.split('\n'):
+ line = line.strip()
+ if summary and not line:
+ break
+ if not line:
+ summary.append('')
+ elif line[0] not in '>|':
+ summary.append(line)
+ summary = '\n'.join(summary)
+
+ # handle the files
+ files = []
+ for (name, type, data) in attachments:
+ files.append(db.file.create(type=type, name=name, content=data))
+
+ # now handle the db stuff
+ if nodeid:
+ # If an item designator (class name and id number) is found there, the
+ # newly created "msg" node is added to the "messages" property for
+ # that item, and any new "file" nodes are added to the "files"
+ # property for the item.
+ message_id = db.msg.create(author=author, recipients=recipients,
+ date=date.Date('.'), summary=summary, content=content,
+ files=files)
+ messages = cl.get(nodeid, 'messages')
+ messages.append(message_id)
+ props['messages'] = messages
+ apply(cl.set, (nodeid, ), props)
+ else:
+ # If just an item class name is found there, we attempt to create a
+ # new item of that class with its "messages" property initialized to
+ # contain the new "msg" node and its "files" property initialized to
+ # contain any new "file" nodes.
+ message_id = db.msg.create(author=author, recipients=recipients,
+ date=date.Date('.'), summary=summary, content=content,
+ files=files)
+ if not props.has_key('assignedto'):
+ props['assignedto'] = 1 # "admin"
+ if not props.has_key('priority'):
+ props['priority'] = 1 # "bug-fatal"
+ if not props.has_key('status'):
+ props['status'] = 1 # "unread"
+ if not props.has_key('title'):
+ props['title'] = title
+ props['messages'] = [message_id]
+ props['nosy'] = recipients[:]
+ props['nosy'].append(author)
+ props['nosy'].sort()
+ nodeid = apply(cl.create, (), props)
+
+ return 0
+
+if __name__ == '__main__':
+ db = roundupdb.openDB(config.DATABASE, 'admin', '1')
+ roundup_mail(db, sys.stdin)
+ db.close()
+
diff --git a/roundup.cgi b/roundup.cgi
--- /dev/null
+++ b/roundup.cgi
@@ -0,0 +1,71 @@
+#!/usr/bin/env python
+
+import sys
+if int(sys.version[0]) < 2:
+ print "Content-Type: text/plain\n"
+ print "Roundup requires Python 2.0 or newer."
+
+import os, traceback, StringIO, cgi, binascii
+
+try:
+ import cgitb
+except:
+ print "Content-Type: text/html\n"
+ print "Failed to import cgitb"
+ print "<pre>"
+ s = StringIO.StringIO()
+ traceback.print_exc(None, s)
+ print cgi.escape(s.getvalue())
+ print "</pre>"
+
+# Force import first from the same directory where this script lives.
+dir, name = os.path.split(sys.argv[0])
+sys.path[:0] = [dir or "."]
+
+def main(out):
+ import config, roundupdb, roundup_cgi
+ db = roundupdb.openDB(config.DATABASE, 'admin')
+ auth = os.environ.get("HTTP_CGI_AUTHORIZATION", None)
+ message = 'Unauthorised'
+ if auth:
+ l = binascii.a2b_base64(auth.split(' ')[1]).split(':')
+ user = l[0]
+ password = None
+ if len(l) > 1:
+ password = l[1]
+ try:
+ uid = db.user.lookup(user)
+ except KeyError:
+ auth = None
+ message = 'Username not recognised'
+ else:
+ if password != db.user.get(uid, 'password'):
+ message = 'Incorrect password'
+ auth = None
+ if not auth:
+ out.write('Content-Type: text/html\n')
+ out.write('Status: 401\n')
+ out.write('WWW-Authenticate: basic realm="Roundup"\n\n')
+ keys = os.environ.keys()
+ keys.sort()
+ out.write(message)
+ return
+ client = roundup_cgi.Client(out, os.environ, user)
+ try:
+ client.main()
+ except roundup_cgi.Unauthorised:
+ out.write('Content-Type: text/html\n')
+ out.write('Status: 403\n\n')
+ out.write('Unauthorised')
+
+out, err = sys.stdout, sys.stderr
+try:
+ import config, roundup_cgi
+ sys.stdout = sys.stderr = open(config.LOG, 'a')
+ main(out)
+except:
+ sys.stdout, sys.stderr = out, err
+ out.write('Content-Type: text/html\n\n')
+ cgitb.handler()
+sys.stdout.flush()
+sys.stdout, sys.stderr = out, err
diff --git a/roundup.py b/roundup.py
--- /dev/null
+++ b/roundup.py
@@ -0,0 +1,204 @@
+#! /usr/bin/python
+
+import sys
+if int(sys.version[0]) < 2:
+ print 'Roundup requires python 2.0 or later.'
+ sys.exit(1)
+
+import string, os, getpass
+import config, date, roundupdb
+
+def determineLogin(argv):
+ n = 2
+ name = password = ''
+ if sys.argv[2] == '-user':
+ l = sys.argv[3].split(':')
+ name = l[0]
+ if len(l) > 1:
+ password = l[1]
+ n = 4
+ elif os.environ.has_key('ROUNDUP_LOGIN'):
+ l = os.environ['ROUNDUP_LOGIN'].split(':')
+ name = l[0]
+ if len(l) > 1:
+ password = l[1]
+ while not name:
+ name = raw_input('Login name: ')
+ while not password:
+ password = getpass.getpass(' password: ')
+ return n, roundupdb.openDB(config.DATABASE, name, password)
+
+def usage():
+ print '''Usage:
+
+ roundup init
+ roundup spec classname
+ roundup create [-user login] classanme propname=value ...
+ roundup list [-list] classname
+ roundup history [-list] designator
+ roundup get [-list] designator[,designator,...] propname
+ roundup set [-user login] designator[,designator,...] propname=value ...
+ roundup find [-list] classname propname=value ...
+ roundup retire designator[,designator,...]
+
+A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
+
+Property values are represented as strings in command arguments and in the
+printed results:
+ . Strings are, well, strings.
+ . Date values are printed in the full date format in the local time zone, and
+ accepted in the full format or any of the partial formats explained below.
+ . Link values are printed as node designators. When given as an argument,
+ node designators and key strings are both accepted.
+ . Multilink values are printed as lists of node designators joined by commas.
+ When given as an argument, node designators and key strings are both
+ accepted; an empty string, a single node, or a list of nodes joined by
+ commas is accepted.
+
+When multiple nodes are specified to the roundup get or roundup set
+commands, the specified properties are retrieved or set on all the listed
+nodes.
+
+When multiple results are returned by the roundup get or roundup find
+commands, they are printed one per line (default) or joined by commas (with
+the -list) option.
+
+Where the command changes data, a login name/password is required. The
+login may be specified as either "name" or "name:password".
+ . ROUNDUP_LOGIN environment variable
+ . the -user command-line option
+If either the name or password is not supplied, they are obtained from the
+command-line.
+
+Date format examples:
+ "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+ "2000-04-17" means <Date 2000-04-17.00:00:00>
+ "01-25" means <Date yyyy-01-25.00:00:00>
+ "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+ "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+ "14:25" means <Date yyyy-mm-dd.19:25:00>
+ "8:47:11" means <Date yyyy-mm-dd.13:47:11>
+ "." means "right now"
+'''
+
+def main():
+
+ if len(sys.argv) == 1:
+ usage()
+ return 1
+
+ command = sys.argv[1]
+ if command == 'init':
+ password = ''
+ confirm = 'x'
+ while password != confirm:
+ password = getpass.getpass('Admin Password:')
+ confirm = getpass.getpass(' Confirm:')
+ roundupdb.initDB(config.DATABASE, password)
+ return 0
+
+ if command == 'get':
+ db = roundupdb.openDB(config.DATABASE)
+ designators = string.split(sys.argv[2], ',')
+ propname = sys.argv[3]
+ for designator in designators:
+ classname, nodeid = roundupdb.splitDesignator(designator)
+ print db.getclass(classname).get(nodeid, propname)
+
+ elif command == 'set':
+ n, db = determineLogin(sys.argv)
+ designators = string.split(sys.argv[n], ',')
+ props = {}
+ for prop in sys.argv[n+1:]:
+ key, value = prop.split('=')
+ props[key] = value
+ for designator in designators:
+ classname, nodeid = roundupdb.splitDesignator(designator)
+ cl = db.getclass(classname)
+ properties = cl.getprops()
+ for key, value in props.items():
+ type = properties[key]
+ if type.isStringType:
+ continue
+ elif type.isDateType:
+ props[key] = date.Date(value)
+ elif type.isIntervalType:
+ props[key] = date.Interval(value)
+ elif type.isLinkType:
+ props[key] = value
+ elif type.isMultilinkType:
+ props[key] = value.split(',')
+ apply(cl.set, (nodeid, ), props)
+
+ elif command == 'find':
+ db = roundupdb.openDB(config.DATABASE)
+ classname = sys.argv[2]
+ cl = db.getclass(classname)
+
+ # look up the linked-to class and get the nodeid that has the value
+ propname, value = sys.argv[3:].split('=')
+ propcl = cl[propname].classname
+ nodeid = propcl.lookup(value)
+
+ # now do the find
+ print cl.find(propname, nodeid)
+
+ elif command == 'spec':
+ db = roundupdb.openDB(config.DATABASE)
+ classname = sys.argv[2]
+ cl = db.getclass(classname)
+ for key, value in cl.properties.items():
+ print '%s: %s'%(key, value)
+
+ elif command == 'create':
+ n, db = determineLogin(sys.argv)
+ classname = sys.argv[n]
+ cl = db.getclass(classname)
+ props = {}
+ properties = cl.getprops()
+ for prop in sys.argv[n+1:]:
+ key, value = prop.split('=')
+ type = properties[key]
+ if type.isStringType:
+ props[key] = value
+ elif type.isDateType:
+ props[key] = date.Date(value)
+ elif type.isIntervalType:
+ props[key] = date.Interval(value)
+ elif type.isLinkType:
+ props[key] = value
+ elif type.isMultilinkType:
+ props[key] = value.split(',')
+ print apply(cl.create, (), props)
+
+ elif command == 'list':
+ db = roundupdb.openDB(config.DATABASE)
+ classname = sys.argv[2]
+ cl = db.getclass(classname)
+ key = cl.getkey() or cl.properties.keys()[0]
+ for nodeid in cl.list():
+ value = cl.get(nodeid, key)
+ print "%4s: %s"%(nodeid, value)
+
+ elif command == 'history':
+ db = roundupdb.openDB(config.DATABASE)
+ classname, nodeid = roundupdb.splitDesignator(sys.argv[2])
+ print db.getclass(classname).history(nodeid)
+
+ elif command == 'retire':
+ n, db = determineLogin(sys.argv)
+ designators = string.split(sys.argv[2], ',')
+ for designator in designators:
+ classname, nodeid = roundupdb.splitDesignator(designator)
+ db.getclass(classname).retire(nodeid)
+
+ else:
+ usage()
+ return 1
+
+ db.close()
+ return 0
+
+if __name__ == '__main__':
+ sys.exit(main())
+
diff --git a/roundup_cgi.py b/roundup_cgi.py
--- /dev/null
+++ b/roundup_cgi.py
@@ -0,0 +1,479 @@
+import os, cgi, pprint, StringIO, urlparse, re, traceback
+
+import config, roundupdb, template, date
+
+class Unauthorised(ValueError):
+ pass
+
+class Client:
+ def __init__(self, out, env, user):
+ self.out = out
+ self.headers_done = 0
+ self.env = env
+ self.path = env.get("PATH_INFO", '').strip()
+ self.user = user
+ self.form = cgi.FieldStorage(environ=env)
+ self.split_path = self.path.split('/')[1:]
+ self.db = roundupdb.openDB(config.DATABASE, self.user)
+ self.headers_done = 0
+ self.debug = 0
+
+ def header(self, headers={'Content-Type':'text/html'}):
+ if not headers.has_key('Content-Type'):
+ headers['Content-Type'] = 'text/html'
+ for entry in headers.items():
+ self.out.write('%s: %s\n'%entry)
+ self.out.write('\n')
+ self.headers_done = 1
+
+ def pagehead(self, title, message=None):
+ url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
+ machine = self.env['SERVER_NAME']
+ port = self.env['SERVER_PORT']
+ if port != '80': machine = machine + ':' + port
+ base = urlparse.urlunparse(('http', machine, url, None, None, None))
+ if message is not None:
+ message = '<div class="system-msg">%s</div>'%message
+ else:
+ message = ''
+ style = open('style.css').read()
+ userid = self.db.user.lookup(self.user)
+ if self.user == 'admin':
+ extras = ' | <a href="list_classes">Class List</a>'
+ else:
+ extras = ''
+ self.write('''<html><head>
+<title>%s</title>
+<style type="text/css">%s</style>
+</head>
+<body bgcolor=#ffffff>
+%s
+<table width=100%% border=0 cellspacing=0 cellpadding=2>
+<tr class="location-bar"><td><big><strong>%s</strong></big></td>
+<td align=right valign=bottom>%s</td></tr>
+<tr class="location-bar">
+<td align=left><a href="issue?:columns=activity,status,title&:group=priority">All issues</a> |
+<a href="issue?priority=fatal-bug,bug">Bugs</a> |
+<a href="issue?priority=usability">Support</a> |
+<a href="issue?priority=feature">Wishlist</a> |
+<a href="newissue">New Issue</a>
+%s</td>
+<td align=right><a href="user%s">Your Details</a></td>
+</table>
+'''%(title, style, message, title, self.user, extras, userid))
+
+ def pagefoot(self):
+ if self.debug:
+ self.write('<hr><small><dl>')
+ self.write('<dt><b>Path</b></dt>')
+ self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
+ keys = self.form.keys()
+ keys.sort()
+ if keys:
+ self.write('<dt><b>Form entries</b></dt>')
+ for k in self.form.keys():
+ v = str(self.form[k].value)
+ self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
+ keys = self.env.keys()
+ keys.sort()
+ self.write('<dt><b>CGI environment</b></dt>')
+ for k in keys:
+ v = self.env[k]
+ self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
+ self.write('</dl></small>')
+ self.write('</body></html>')
+
+ def write(self, content):
+ if not self.headers_done:
+ self.header()
+ self.out.write(content)
+
+ def index_arg(self, arg):
+ ''' handle the args to index - they might be a list from the form
+ (ie. submitted from a form) or they might be a command-separated
+ single string (ie. manually constructed GET args)
+ '''
+ if self.form.has_key(arg):
+ arg = self.form[arg]
+ if type(arg) == type([]):
+ return [arg.value for arg in arg]
+ return arg.value.split(',')
+ return []
+
+ def index(self):
+ ''' put up an index
+ '''
+ self.classname = 'issue'
+ if self.form.has_key(':sort'): sort = self.index_arg(':sort')
+ else: sort=['-activity']
+ if self.form.has_key(':group'): group = self.index_arg(':group')
+ else: group=['priority']
+ if self.form.has_key(':filter'): filter = self.index_arg(':filter')
+ else: filter = []
+ if self.form.has_key(':columns'): columns = self.index_arg(':columns')
+ else: columns=['activity','status','title']
+ return self.list(columns=columns, filter=filter, group=group, sort=sort)
+
+ # XXX deviates from spec - loses the '+' (that's a reserved character
+ # in URLS
+ def list(self, sort=None, group=None, filter=None, columns=None):
+ ''' call the template index with the args
+
+ :sort - sort by prop name, optionally preceeded with '-'
+ to give descending or nothing for ascending sorting.
+ :group - group by prop name, optionally preceeded with '-' or
+ to sort in descending or nothing for ascending order.
+ :filter - selects which props should be displayed in the filter
+ section. Default is all.
+ :columns - selects the columns that should be displayed.
+ Default is all.
+
+ '''
+ cn = self.classname
+ self.pagehead('Index: %s'%cn)
+ if sort is None: sort = self.index_arg(':sort')
+ if group is None: group = self.index_arg(':group')
+ if filter is None: filter = self.index_arg(':filter')
+ if columns is None: columns = self.index_arg(':columns')
+
+ # all the other form args are filters
+ filterspec = {}
+ for key in self.form.keys():
+ if key[0] == ':': continue
+ value = self.form[key]
+ if type(value) == type([]):
+ value = [arg.value for arg in value]
+ else:
+ value = value.value.split(',')
+ l = filterspec.get(key, [])
+ l = l + value
+ filterspec[key] = l
+
+ template.index(self, self.db, cn, filterspec, filter, columns, sort,
+ group)
+ self.pagefoot()
+
+ def showitem(self, message=None):
+ ''' display an item
+ '''
+ cn = self.classname
+ cl = self.db.classes[cn]
+
+ # possibly perform an edit
+ keys = self.form.keys()
+ num_re = re.compile('^\d+$')
+ if keys:
+ changed = []
+ props = {}
+ try:
+ keys = self.form.keys()
+ for key in keys:
+ if not cl.properties.has_key(key):
+ continue
+ proptype = cl.properties[key]
+ if proptype.isStringType:
+ value = str(self.form[key].value).strip()
+ elif proptype.isDateType:
+ value = date.Date(str(self.form[key].value))
+ elif proptype.isIntervalType:
+ value = date.Interval(str(self.form[key].value))
+ elif proptype.isLinkType:
+ value = str(self.form[key].value).strip()
+ # handle key values
+ link = cl.properties[key].classname
+ if not num_re.match(value):
+ try:
+ value = self.db.classes[link].lookup(value)
+ except:
+ raise ValueError, 'property "%s": %s not a %s'%(
+ key, value, link)
+ elif proptype.isMultilinkType:
+ value = self.form[key]
+ if type(value) != type([]):
+ value = [i.strip() for i in str(value.value).split(',')]
+ else:
+ value = [str(i.value).strip() for i in value]
+ link = cl.properties[key].classname
+ l = []
+ for entry in map(str, value):
+ if not num_re.match(entry):
+ try:
+ entry = self.db.classes[link].lookup(entry)
+ except:
+ raise ValueError, \
+ 'property "%s": %s not a %s'%(key,
+ entry, link)
+ l.append(entry)
+ l.sort()
+ value = l
+ # if changed, set it
+ if value != cl.get(self.nodeid, key):
+ changed.append(key)
+ props[key] = value
+ cl.set(self.nodeid, **props)
+
+ # if this item has messages,
+ if (cl.getprops().has_key('messages') and
+ cl.getprops()['messages'].isMultilinkType and
+ cl.getprops()['messages'].classname == 'msg'):
+ # generate an edit message - nosyreactor will send it
+ nid = self.nodeid
+ m = []
+ for name, prop in cl.getprops().items():
+ value = cl.get(nid, name)
+ if prop.isLinkType:
+ link = self.db.classes[prop.classname]
+ key = link.getkey()
+ if value is not None and key:
+ value = link.get(value, key)
+ else:
+ value = '-'
+ elif prop.isMultilinkType:
+ l = []
+ link = self.db.classes[prop.classname]
+ for entry in value:
+ key = link.getkey()
+ if key:
+ l.append(link.get(entry, link.getkey()))
+ else:
+ l.append(entry)
+ value = ', '.join(l)
+ if name in changed:
+ chg = '*'
+ else:
+ chg = ' '
+ m.append('%s %s: %s'%(chg, name, value))
+
+ # handle the note
+ if self.form.has_key('__note'):
+ note = self.form['__note'].value
+ if '\n' in note:
+ summary = re.split(r'\n\r?', note)[0]
+ else:
+ summary = note
+ m.append('\n%s\n'%note)
+ else:
+ if len(changed) > 1:
+ plural = 's were'
+ else:
+ plural = ' was'
+ summary = 'This %s has been edited through the web '\
+ 'and the %s value%s changed.'%(cn,
+ ', '.join(changed), plural)
+ m.append('\n%s\n'%summary)
+
+ # now create the message
+ content = '\n'.join(m)
+ message_id = self.db.msg.create(author=1, recipients=[],
+ date=date.Date('.'), summary=summary, content=content)
+ messages = cl.get(nid, 'messages')
+ messages.append(message_id)
+ props = {'messages': messages}
+ cl.set(nid, **props)
+
+ # and some nice feedback for the user
+ message = '%s edited ok'%', '.join(changed)
+ except:
+ s = StringIO.StringIO()
+ traceback.print_exc(None, s)
+ message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
+
+ # now the display
+ id = self.nodeid
+ if cl.getkey():
+ id = cl.get(id, cl.getkey())
+ self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
+
+ nodeid = self.nodeid
+
+ # use the template to display the item
+ template.item(self, self.db, self.classname, nodeid)
+ self.pagefoot()
+ showissue = showitem
+ showmsg = showitem
+
+ def newissue(self, message=None):
+ ''' add an issue
+ '''
+ cn = self.classname
+ cl = self.db.classes[cn]
+
+ # possibly perform a create
+ keys = self.form.keys()
+ num_re = re.compile('^\d+$')
+ if keys:
+ props = {}
+ try:
+ keys = self.form.keys()
+ for key in keys:
+ if not cl.properties.has_key(key):
+ continue
+ proptype = cl.properties[key]
+ if proptype.isStringType:
+ value = str(self.form[key].value).strip()
+ elif proptype.isDateType:
+ value = date.Date(str(self.form[key].value))
+ elif proptype.isIntervalType:
+ value = date.Interval(str(self.form[key].value))
+ elif proptype.isLinkType:
+ value = str(self.form[key].value).strip()
+ # handle key values
+ link = cl.properties[key].classname
+ if not num_re.match(value):
+ try:
+ value = self.db.classes[link].lookup(value)
+ except:
+ raise ValueError, 'property "%s": %s not a %s'%(
+ key, value, link)
+ elif proptype.isMultilinkType:
+ value = self.form[key]
+ if type(value) != type([]):
+ value = [i.strip() for i in str(value.value).split(',')]
+ else:
+ value = [str(i.value).strip() for i in value]
+ link = cl.properties[key].classname
+ l = []
+ for entry in map(str, value):
+ if not num_re.match(entry):
+ try:
+ entry = self.db.classes[link].lookup(entry)
+ except:
+ raise ValueError, \
+ 'property "%s": %s not a %s'%(key,
+ entry, link)
+ l.append(entry)
+ l.sort()
+ value = l
+ props[key] = value
+ nid = cl.create(**props)
+
+ # if this item has messages,
+ if (cl.getprops().has_key('messages') and
+ cl.getprops()['messages'].isMultilinkType and
+ cl.getprops()['messages'].classname == 'msg'):
+ # generate an edit message - nosyreactor will send it
+ m = []
+ for name, prop in cl.getprops().items():
+ value = cl.get(nid, name)
+ if prop.isLinkType:
+ link = self.db.classes[prop.classname]
+ key = link.getkey()
+ if value is not None and key:
+ value = link.get(value, key)
+ else:
+ value = '-'
+ elif prop.isMultilinkType:
+ l = []
+ link = self.db.classes[prop.classname]
+ for entry in value:
+ key = link.getkey()
+ if key:
+ l.append(link.get(entry, link.getkey()))
+ else:
+ l.append(entry)
+ value = ', '.join(l)
+ m.append('%s: %s'%(name, value))
+
+ # handle the note
+ if self.form.has_key('__note'):
+ note = self.form['__note'].value
+ if '\n' in note:
+ summary = re.split(r'\n\r?', note)[0]
+ else:
+ summary = note
+ m.append('\n%s\n'%note)
+ else:
+ if len(changed) > 1:
+ plural = 's were'
+ else:
+ plural = ' was'
+ summary = 'This %s has been created through the web.'%cn
+ m.append('\n%s\n'%summary)
+
+ # now create the message
+ content = '\n'.join(m)
+ message_id = self.db.msg.create(author=1, recipients=[],
+ date=date.Date('.'), summary=summary, content=content)
+ messages = cl.get(nid, 'messages')
+ messages.append(message_id)
+ props = {'messages': messages}
+ cl.set(nid, **props)
+
+ # and some nice feedback for the user
+ message = '%s created ok'%cn
+ except:
+ s = StringIO.StringIO()
+ traceback.print_exc(None, s)
+ message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
+ self.pagehead('New %s'%self.classname.capitalize(), message)
+ template.newitem(self, self.db, self.classname, self.form)
+ self.pagefoot()
+
+ def showuser(self, message=None):
+ ''' display an item
+ '''
+ if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
+ self.showitem(message)
+ else:
+ raise Unauthorised
+
+ def showfile(self):
+ ''' display a file
+ '''
+ nodeid = self.nodeid
+ cl = self.db.file
+ type = cl.get(nodeid, 'type')
+ if type == 'message/rfc822':
+ type = 'text/plain'
+ self.header(headers={'Content-Type': type})
+ self.write(cl.get(nodeid, 'content'))
+
+ def classes(self, message=None):
+ ''' display a list of all the classes in the database
+ '''
+ if self.user == 'admin':
+ self.pagehead('Table of classes', message)
+ classnames = self.db.classes.keys()
+ classnames.sort()
+ self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
+ for cn in classnames:
+ cl = self.db.getclass(cn)
+ self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
+ for key, value in cl.properties.items():
+ if value is None: value = ''
+ else: value = str(value)
+ self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
+ key, cgi.escape(value)))
+ self.write('</table>')
+ self.pagefoot()
+ else:
+ raise Unauthorised
+
+ def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
+ nre=re.compile(r'new(\w+)')):
+ path = self.split_path
+ if not path or path[0] in ('', 'index'):
+ self.index()
+ elif len(path) == 1:
+ if path[0] == 'list_classes':
+ self.classes()
+ return
+ m = dre.match(path[0])
+ if m:
+ self.classname = m.group(1)
+ self.nodeid = m.group(2)
+ getattr(self, 'show%s'%self.classname)()
+ return
+ m = nre.match(path[0])
+ if m:
+ self.classname = m.group(1)
+ getattr(self, 'new%s'%self.classname)()
+ return
+ self.classname = path[0]
+ self.list()
+ else:
+ raise 'ValueError', 'Path not understood'
+
+ def __del__(self):
+ self.db.close()
+
diff --git a/roundupdb.py b/roundupdb.py
--- /dev/null
+++ b/roundupdb.py
@@ -0,0 +1,371 @@
+import re, os, smtplib, socket
+
+import config, hyperdb, date
+
+def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
+ ''' Take a foo123 and return ('foo', 123)
+ '''
+ m = dre.match(designator)
+ return m.group(1), m.group(2)
+
+class Database(hyperdb.Database):
+ def getuid(self):
+ """Return the id of the "user" node associated with the user
+ that owns this connection to the hyperdatabase."""
+ return self.user.lookup(self.journaltag)
+
+ def uidFromAddress(self, address):
+ ''' address is from the rfc822 module, and therefore is (name, addr)
+ '''
+ (realname, address) = address
+ users = self.user.stringFind(address=address)
+ if users: return users[0]
+ return self.user.create(username=address, address=address,
+ realname=realname)
+
+class Class(hyperdb.Class):
+ # Overridden methods:
+ def __init__(self, db, classname, **properties):
+ hyperdb.Class.__init__(self, db, classname, **properties)
+ self.auditors = {'create': [], 'set': [], 'retire': []}
+ self.reactors = {'create': [], 'set': [], 'retire': []}
+
+ def create(self, **propvalues):
+ """These operations trigger detectors and can be vetoed. Attempts
+ to modify the "creation" or "activity" properties cause a KeyError.
+ """
+ if propvalues.has_key('creation') or propvalues.has_key('activity'):
+ raise KeyError, '"creation" and "activity" are reserved'
+ for audit in self.auditors['create']:
+ audit(self.db, self, None, propvalues)
+ nodeid = hyperdb.Class.create(self, **propvalues)
+ for react in self.reactors['create']:
+ react(self.db, self, nodeid, None)
+ return nodeid
+
+ def set(self, nodeid, **propvalues):
+ """These operations trigger detectors and can be vetoed. Attempts
+ to modify the "creation" or "activity" properties cause a KeyError.
+ """
+ if propvalues.has_key('creation') or propvalues.has_key('activity'):
+ raise KeyError, '"creation" and "activity" are reserved'
+ for audit in self.auditors['set']:
+ audit(self.db, self, nodeid, propvalues)
+ oldvalues = self.db.getnode(self.classname, nodeid)
+ hyperdb.Class.set(self, nodeid, **propvalues)
+ for react in self.reactors['set']:
+ react(self.db, self, nodeid, oldvalues)
+
+ def retire(self, nodeid):
+ """These operations trigger detectors and can be vetoed. Attempts
+ to modify the "creation" or "activity" properties cause a KeyError.
+ """
+ for audit in self.auditors['retire']:
+ audit(self.db, self, nodeid, None)
+ hyperdb.Class.retire(self, nodeid)
+ for react in self.reactors['retire']:
+ react(self.db, self, nodeid, None)
+
+ # New methods:
+
+ def audit(self, event, detector):
+ """Register a detector
+ """
+ self.auditors[event].append(detector)
+
+ def react(self, event, detector):
+ """Register a detector
+ """
+ self.reactors[event].append(detector)
+
+class FileClass(Class):
+ def create(self, **propvalues):
+ ''' snaffle the file propvalue and store in a file
+ '''
+ content = propvalues['content']
+ del propvalues['content']
+ newid = Class.create(self, **propvalues)
+ self.setcontent(self.classname, newid, content)
+ return newid
+
+ def filename(self, classname, nodeid):
+ # TODO: split into multiple files directories
+ return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
+
+ def setcontent(self, classname, nodeid, content):
+ ''' set the content file for this file
+ '''
+ open(self.filename(classname, nodeid), 'wb').write(content)
+
+ def getcontent(self, classname, nodeid):
+ ''' get the content file for this file
+ '''
+ return open(self.filename(classname, nodeid), 'rb').read()
+
+ def get(self, nodeid, propname):
+ ''' trap the content propname and get it from the file
+ '''
+ if propname == 'content':
+ return self.getcontent(self.classname, nodeid)
+ return Class.get(self, nodeid, propname)
+
+ def getprops(self):
+ ''' In addition to the actual properties on the node, these methods
+ provide the "content" property.
+ '''
+ d = Class.getprops(self).copy()
+ d['content'] = hyperdb.String()
+ return d
+
+# XXX deviation from spec
+class IssueClass(Class):
+ # Overridden methods:
+
+ def __init__(self, db, classname, **properties):
+ """The newly-created class automatically includes the "messages",
+ "files", "nosy", and "superseder" properties. If the 'properties'
+ dictionary attempts to specify any of these properties or a
+ "creation" or "activity" property, a ValueError is raised."""
+ if not properties.has_key('title'):
+ properties['title'] = hyperdb.String()
+ if not properties.has_key('messages'):
+ properties['messages'] = hyperdb.Multilink("msg")
+ if not properties.has_key('files'):
+ properties['files'] = hyperdb.Multilink("file")
+ if not properties.has_key('nosy'):
+ properties['nosy'] = hyperdb.Multilink("user")
+ if not properties.has_key('superseder'):
+ properties['superseder'] = hyperdb.Multilink("issue")
+ if (properties.has_key('creation') or properties.has_key('activity')
+ or properties.has_key('creator')):
+ raise ValueError, '"creation", "activity" and "creator" are reserved'
+ Class.__init__(self, db, classname, **properties)
+
+ def get(self, nodeid, propname):
+ if propname == 'creation':
+ return self.db.getjournal(self.classname, nodeid)[0][1]
+ if propname == 'activity':
+ return self.db.getjournal(self.classname, nodeid)[-1][1]
+ if propname == 'creator':
+ name = self.db.getjournal(self.classname, nodeid)[0][2]
+ return self.db.user.lookup(name)
+ return Class.get(self, nodeid, propname)
+
+ def getprops(self):
+ """In addition to the actual properties on the node, these
+ methods provide the "creation" and "activity" properties."""
+ d = Class.getprops(self).copy()
+ d['creation'] = hyperdb.Date()
+ d['activity'] = hyperdb.Date()
+ d['creator'] = hyperdb.Link("user")
+ return d
+
+ # New methods:
+
+ def addmessage(self, nodeid, summary, text):
+ """Add a message to an issue's mail spool.
+
+ A new "msg" node is constructed using the current date, the user that
+ owns the database connection as the author, and the specified summary
+ text.
+
+ The "files" and "recipients" fields are left empty.
+
+ The given text is saved as the body of the message and the node is
+ appended to the "messages" field of the specified issue.
+ """
+
+ def sendmessage(self, nodeid, msgid):
+ """Send a message to the members of an issue's nosy list.
+
+ The message is sent only to users on the nosy list who are not
+ already on the "recipients" list for the message.
+
+ These users are then added to the message's "recipients" list.
+ """
+ # figure the recipient ids
+ recipients = self.db.msg.get(msgid, 'recipients')
+ r = {}
+ for recipid in recipients:
+ r[recipid] = 1
+ authid = self.db.msg.get(msgid, 'author')
+ r[authid] = 1
+
+ # now figure the nosy people who weren't recipients
+ sendto = []
+ nosy = self.get(nodeid, 'nosy')
+ for nosyid in nosy:
+ if not r.has_key(nosyid):
+ sendto.append(nosyid)
+ recipients.append(nosyid)
+
+ if sendto:
+ # update the message's recipients list
+ self.db.msg.set(msgid, recipients=recipients)
+
+ # send an email to the people who missed out
+ sendto = [self.db.user.get(i, 'address') for i in recipients]
+ cn = self.classname
+ title = self.get(nodeid, 'title') or '%s message copy'%cn
+ m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
+ m.append('To: %s'%', '.join(sendto))
+ m.append('Reply-To: %s'%config.ISSUE_TRACKER_EMAIL)
+ m.append('')
+ m.append(self.db.msg.get(msgid, 'content'))
+ # TODO attachments
+ try:
+ smtp = smtplib.SMTP(config.MAILHOST)
+ smtp.sendmail(config.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
+ except socket.error, value:
+ return "Couldn't send confirmation email: mailhost %s"%value
+ except smtplib.SMTPException, value:
+ return "Couldn't send confirmation email: %s"%value
+
+def nosyreaction(db, cl, nodeid, oldvalues):
+ ''' A standard detector is provided that watches for additions to the
+ "messages" property.
+
+ When a new message is added, the detector sends it to all the users on
+ the "nosy" list for the issue that are not already on the "recipients"
+ list of the message.
+
+ Those users are then appended to the "recipients" property on the
+ message, so multiple copies of a message are never sent to the same
+ user.
+
+ The journal recorded by the hyperdatabase on the "recipients" property
+ then provides a log of when the message was sent to whom.
+ '''
+ messages = []
+ if oldvalues is None:
+ # the action was a create, so use all the messages in the create
+ messages = cl.get(nodeid, 'messages')
+ elif oldvalues.has_key('messages'):
+ # the action was a set (so adding new messages to an existing issue)
+ m = {}
+ for msgid in oldvalues['messages']:
+ m[msgid] = 1
+ messages = []
+ # figure which of the messages now on the issue weren't there before
+ for msgid in cl.get(nodeid, 'messages'):
+ if not m.has_key(msgid):
+ messages.append(msgid)
+ if not messages:
+ return
+
+ # send a copy to the nosy list
+ for msgid in messages:
+ cl.sendmessage(nodeid, msgid)
+
+ # update the nosy list with the recipients from the new messages
+ nosy = cl.get(nodeid, 'nosy')
+ n = {}
+ for nosyid in nosy: n[nosyid] = 1
+ change = 0
+ # but don't add admin to the nosy list
+ for msgid in messages:
+ for recipid in db.msg.get(msgid, 'recipients'):
+ if recipid != '1' and not n.has_key(recipid):
+ change = 1
+ nosy.append(recipid)
+ authid = db.msg.get(msgid, 'author')
+ if authid != '1' and not n.has_key(authid):
+ change = 1
+ nosy.append(authid)
+ if change:
+ cl.set(nodeid, nosy=nosy)
+
+def openDB(storagelocator, name=None, password=None):
+ ''' Open the Roundup DB
+
+ ... configs up all the classes etc
+ '''
+ db = Database(storagelocator, name)
+ pri = Class(db, "priority", name=hyperdb.String(), order=hyperdb.String())
+ pri.setkey("name")
+ stat = Class(db, "status", name=hyperdb.String(), order=hyperdb.String())
+ stat.setkey("name")
+ Class(db, "keyword", name=hyperdb.String())
+ user = Class(db, "user", username=hyperdb.String(),
+ password=hyperdb.String(), address=hyperdb.String(),
+ realname=hyperdb.String(), phone=hyperdb.String(),
+ organisation=hyperdb.String())
+ user.setkey("username")
+ msg = FileClass(db, "msg", author=hyperdb.Link("user"),
+ recipients=hyperdb.Multilink("user"), date=hyperdb.Date(),
+ summary=hyperdb.String(), files=hyperdb.Multilink("file"))
+ file = FileClass(db, "file", name=hyperdb.String(), type=hyperdb.String())
+
+ # bugs and support calls etc
+ rate = Class(db, "rate", name=hyperdb.String(), order=hyperdb.String())
+ rate.setkey("name")
+ source = Class(db, "source", name=hyperdb.String(), order=hyperdb.String())
+ source.setkey("name")
+ platform = Class(db, "platform", name=hyperdb.String(), order=hyperdb.String())
+ platform.setkey("name")
+ product = Class(db, "product", name=hyperdb.String(), order=hyperdb.String())
+ product.setkey("name")
+ Class(db, "timelog", date=hyperdb.Date(), time=hyperdb.String(),
+ performedby=hyperdb.Link("user"), description=hyperdb.String())
+ issue = IssueClass(db, "issue", assignedto=hyperdb.Link("user"),
+ priority=hyperdb.Link("priority"), status=hyperdb.Link("status"),
+ rate=hyperdb.Link("rate"), source=hyperdb.Link("source"),
+ product=hyperdb.Link("product"), platform=hyperdb.Multilink("platform"),
+ version=hyperdb.String(),
+ timelog=hyperdb.Multilink("timelog"), customername=hyperdb.String())
+ issue.setkey('title')
+ issue.react('create', nosyreaction)
+ issue.react('set', nosyreaction)
+ return db
+
+def initDB(storagelocator, password):
+ ''' Initialise the Roundup DB for use
+ '''
+ dbdir = os.path.join(storagelocator, 'files')
+ if not os.path.isdir(dbdir):
+ os.makedirs(dbdir)
+ db = openDB(storagelocator, "admin")
+ db.clear()
+ pri = db.getclass('priority')
+ pri.create(name="fatal-bug", order="1")
+ pri.create(name="bug", order="2")
+ pri.create(name="usability", order="3")
+ pri.create(name="feature", order="4")
+
+ stat = db.getclass('status')
+ 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")
+
+ rate = db.getclass("rate")
+ rate.create(name='basic', order="1")
+ rate.create(name='premium', order="2")
+ rate.create(name='internal', order="3")
+
+ source = db.getclass("source")
+ source.create(name='phone', order="1")
+ source.create(name='e-mail', order="2")
+ source.create(name='internal', order="3")
+ source.create(name='internal-qa', order="4")
+
+ platform = db.getclass("platform")
+ platform.create(name='linux', order="1")
+ platform.create(name='windows', order="2")
+ platform.create(name='mac', order="3")
+
+ product = db.getclass("product")
+ product.create(name='Bizar Shop', order="1")
+ product.create(name='Bizar Shop Developer', order="2")
+ product.create(name='Bizar Shop Manual', order="3")
+ product.create(name='Bizar Shop Developer Manual', order="4")
+
+ user = db.getclass('user')
+ user.create(username="admin", password=password, address=config.ADMIN_EMAIL)
+
+ db.close()
+
diff --git a/server.py b/server.py
--- /dev/null
+++ b/server.py
@@ -0,0 +1,150 @@
+#!/usr/bin/python
+""" HTTP Server that serves roundup.
+
+Stolen from CGIHTTPServer
+
+"""
+import sys
+if int(sys.version[0]) < 2:
+ print "Content-Type: text/plain\n"
+ print "Roundup requires Python 2.0 or newer."
+
+__version__ = "0.1"
+
+__all__ = ["CGIHTTPRequestHandler"]
+
+import os, urllib, StringIO, traceback, cgi, binascii
+import BaseHTTPServer
+import SimpleHTTPServer
+import date, hyperdb, template, roundupdb, roundup_cgi
+import cgitb
+
+class RoundupRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
+ def send_head(self):
+ """Version of send_head that support CGI scripts"""
+ return self.run_cgi()
+
+ def run_cgi(self):
+ """Execute a CGI script."""
+ rest = self.path
+ i = rest.rfind('?')
+ if i >= 0:
+ rest, query = rest[:i], rest[i+1:]
+ else:
+ query = ''
+
+ # Set up the CGI environment
+ env = {}
+ env['REQUEST_METHOD'] = self.command
+ env['PATH_INFO'] = urllib.unquote(rest)
+ if query:
+ env['QUERY_STRING'] = query
+ host = self.address_string()
+ if self.headers.typeheader is None:
+ env['CONTENT_TYPE'] = self.headers.type
+ else:
+ env['CONTENT_TYPE'] = self.headers.typeheader
+ length = self.headers.getheader('content-length')
+ if length:
+ env['CONTENT_LENGTH'] = length
+ co = filter(None, self.headers.getheaders('cookie'))
+ if co:
+ env['HTTP_COOKIE'] = ', '.join(co)
+ env['SCRIPT_NAME'] = ''
+ env['SERVER_NAME'] = self.server.server_name
+ env['SERVER_PORT'] = str(self.server.server_port)
+
+ decoded_query = query.replace('+', ' ')
+
+ # if root, setuid to nobody
+ if not os.getuid():
+ nobody = nobody_uid()
+ os.setuid(nobody)
+
+ # TODO check for file timestamp changes
+ reload(date)
+ reload(hyperdb)
+ reload(roundupdb)
+ reload(template)
+ reload(roundup_cgi)
+
+ # initialise the roundupdb, check for auth
+ db = roundupdb.openDB('db', 'admin')
+ message = 'Unauthorised'
+ auth = self.headers.getheader('authorization')
+ if auth:
+ l = binascii.a2b_base64(auth.split(' ')[1]).split(':')
+ user = l[0]
+ password = None
+ if len(l) > 1:
+ password = l[1]
+ try:
+ uid = db.user.lookup(user)
+ except KeyError:
+ auth = None
+ message = 'Username not recognised'
+ else:
+ if password != db.user.get(uid, 'password'):
+ message = 'Incorrect password'
+ auth = None
+ db.close()
+ del db
+ if not auth:
+ self.send_response(401)
+ self.send_header('Content-Type', 'text/html')
+ self.send_header('WWW-Authenticate', 'basic realm="Roundup"')
+ self.end_headers()
+ self.wfile.write(message)
+ return
+
+ self.send_response(200, "Script output follows")
+
+ # do the roundup thang
+ save_stdin = sys.stdin
+ try:
+ sys.stdin = self.rfile
+ client = roundup_cgi.Client(self.wfile, env, user)
+ client.main()
+ except roundup_cgi.Unauthorised:
+ self.wfile.write('Content-Type: text/html\n')
+ self.wfile.write('Status: 403\n')
+ self.wfile.write('Unauthorised')
+ except:
+ try:
+ reload(cgitb)
+ self.wfile.write(cgitb.breaker())
+ self.wfile.write(cgitb.html())
+ except:
+ self.wfile.write("Content-Type: text/html\n\n")
+ self.wfile.write("<pre>")
+ s = StringIO.StringIO()
+ traceback.print_exc(None, s)
+ self.wfile.write(cgi.escape(s.getvalue()))
+ self.wfile.write("</pre>\n")
+ sys.stdin = save_stdin
+ do_POST = run_cgi
+
+
+nobody = None
+
+def nobody_uid():
+ """Internal routine to get nobody's uid"""
+ global nobody
+ if nobody:
+ return nobody
+ try:
+ import pwd
+ except ImportError:
+ return -1
+ try:
+ nobody = pwd.getpwnam('nobody')[2]
+ except KeyError:
+ nobody = 1 + max(map(lambda x: x[2], pwd.getpwall()))
+ return nobody
+
+if __name__ == '__main__':
+ address = ('dirk.adroit', 9080)
+ httpd = BaseHTTPServer.HTTPServer(address, RoundupRequestHandler)
+ print 'Roundup server started on', address
+ httpd.serve_forever()
+
diff --git a/style.css b/style.css
--- /dev/null
+++ b/style.css
@@ -0,0 +1,163 @@
+h1 {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 18pt;
+ font-weight: bold;
+}
+
+h2 {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 16pt;
+ font-weight: bold;
+}
+
+h3 {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 12pt;
+ font-weight: bold;
+}
+
+a:hover {
+ font-family: Verdana, Helvetica, sans-serif;
+ text-decoration: underline;
+ color: #333333;
+}
+
+a:link {
+ font-family: Verdana, Helvetica, sans-serif;
+ text-decoration: none;
+ color: #000099;
+}
+
+a {
+ font-family: Verdana, Helvetica, sans-serif;
+ text-decoration: none;
+ color: #000099;
+}
+
+p {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 10pt;
+ color: #333333;
+}
+
+th {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-weight: bold;
+ font-size: 10pt;
+ color: #333333;
+}
+
+.form-help {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 10pt;
+ color: #333333;
+}
+
+.std-text {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 10pt;
+ color: #333333;
+}
+
+.tab-small {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 8pt;
+ color: #333333;
+}
+
+.location-bar {
+ background-color: #efefef;
+ border: none;
+}
+
+.strong-header {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 12pt;
+ font-weight: bold;
+ background-color: #000000;
+ color: #ffffff;
+}
+
+.list-header {
+ background-color: #c0c0c0;
+ border: none;
+}
+
+.list-item {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 10pt;
+}
+
+.list-nav {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 10pt;
+ font-weight: bold;
+}
+
+.row-normal {
+ background-color: #ffffff;
+ border: none;
+
+}
+
+.row-hilite {
+ background-color: #efefef;
+ border: none;
+}
+
+.section-bar {
+ background-color: #c0c0c0;
+ border: none;
+}
+
+.system-msg {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 10pt;
+ background-color: #ffffff;
+ border: 1px solid #000000;
+ margin-bottom: 6px;
+ margin-top: 6px;
+ padding: 4px;
+ width: 100%;
+ color: #660033;
+}
+
+.form-title {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-weight: bold;
+ font-size: 12pt;
+ color: #333333;
+}
+
+.form-label {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-weight: bold;
+ font-size: 10pt;
+ color: #333333;
+}
+
+.form-optional {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-weight: bold;
+ font-style: italic;
+ font-size: 10pt;
+ color: #333333;
+}
+
+.form-element {
+ font-family: Verdana, Helvetica, aans-serif;
+ font-size: 10pt;
+ color: #000000;
+}
+
+.form-text {
+ font-family: Verdana, Helvetica, sans-serif;
+ font-size: 10pt;
+ color: #333333;
+}
+
+.form-mono {
+ font-family: monospace;
+ font-size: 12px;
+ text-decoration: none;
+}
diff --git a/template.py b/template.py
--- /dev/null
+++ b/template.py
@@ -0,0 +1,692 @@
+import os, re, StringIO, urllib
+
+import hyperdb, date
+
+class Base:
+ def __init__(self, db, classname, nodeid=None, form=None):
+ self.db, self.classname, self.nodeid = db, classname, nodeid
+ self.form = form
+ self.cl = self.db.classes[self.classname]
+ self.properties = self.cl.getprops()
+
+class Plain(Base):
+ ''' 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)
+ '''
+ def __call__(self, property):
+ if not self.nodeid and self.form is None:
+ return '[Field: not called from item]'
+ propclass = self.properties[property]
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ # TODO: pull the value from the form
+ if propclass.isMultilinkType: value = []
+ else: value = ''
+ if propclass.isStringType:
+ if value is None: value = ''
+ else: value = str(value)
+ elif propclass.isDateType:
+ value = str(value)
+ elif propclass.isIntervalType:
+ value = str(value)
+ elif propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ if value: value = str(linkcl.get(value, linkcl.getkey()))
+ else: value = '[unselected]'
+ elif propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ k = linkcl.getkey()
+ value = ', '.join([linkcl.get(i, k) for i in value])
+ else:
+ s = 'Plain: bad propclass "%s"'%propclass
+ return value
+
+class Field(Base):
+ ''' display a property like the plain displayer, but in a text field
+ to be edited
+ '''
+ def __call__(self, property, size=None, height=None, showid=0):
+ if not self.nodeid and self.form is None:
+ return '[Field: not called from item]'
+ propclass = self.properties[property]
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ # TODO: pull the value from the form
+ if propclass.isMultilinkType: value = []
+ else: value = ''
+ if (propclass.isStringType or propclass.isDateType or
+ propclass.isIntervalType):
+ size = size or 30
+ if value is None:
+ value = ''
+ s = '<input name="%s" value="%s" size="%s">'%(property, value, size)
+ elif propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = ['<select name="%s">'%property]
+ k = linkcl.getkey()
+ for optionid in linkcl.list():
+ option = linkcl.get(optionid, k)
+ s = ''
+ if optionid == value:
+ s = 'selected '
+ if showid:
+ lab = '%s%s: %s'%(propclass.classname, optionid, option)
+ else:
+ lab = option
+ if size is not None and len(lab) > size:
+ lab = lab[:size-3] + '...'
+ l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+ l.append('</select>')
+ s = '\n'.join(l)
+ elif propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ list = linkcl.list()
+ height = height or min(len(list), 7)
+ l = ['<select multiple name="%s" size="%s">'%(property, height)]
+ k = linkcl.getkey()
+ for optionid in list:
+ option = linkcl.get(optionid, k)
+ s = ''
+ if optionid in value:
+ s = 'selected '
+ if showid:
+ lab = '%s%s: %s'%(propclass.classname, optionid, option)
+ else:
+ lab = option
+ if size is not None and len(lab) > size:
+ lab = lab[:size-3] + '...'
+ l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+ l.append('</select>')
+ s = '\n'.join(l)
+ else:
+ s = 'Plain: bad propclass "%s"'%propclass
+ return s
+
+class Menu(Base):
+ ''' for a Link property, display a menu of the available choices
+ '''
+ def __call__(self, property, size=None, height=None, showid=0):
+ propclass = self.properties[property]
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ # TODO: pull the value from the form
+ if propclass.isMultilinkType: value = []
+ else: value = None
+ if propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = ['<select name="%s">'%property]
+ k = linkcl.getkey()
+ for optionid in linkcl.list():
+ option = linkcl.get(optionid, k)
+ s = ''
+ if optionid == value:
+ s = 'selected '
+ l.append('<option %svalue="%s">%s</option>'%(s, optionid, option))
+ l.append('</select>')
+ return '\n'.join(l)
+ if propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ list = linkcl.list()
+ height = height or min(len(list), 7)
+ l = ['<select multiple name="%s" size="%s">'%(property, height)]
+ k = linkcl.getkey()
+ for optionid in list:
+ option = linkcl.get(optionid, k)
+ s = ''
+ if optionid in value:
+ s = 'selected '
+ if showid:
+ lab = '%s%s: %s'%(propclass.classname, optionid, option)
+ else:
+ lab = option
+ if size is not None and len(lab) > size:
+ lab = lab[:size-3] + '...'
+ l.append('<option %svalue="%s">%s</option>'%(s, optionid, option))
+ l.append('</select>')
+ return '\n'.join(l)
+ return '[Menu: not a link]'
+
+#XXX deviates from spec
+class Link(Base):
+ ''' 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
+ '''
+ def __call__(self, property=None, **args):
+ if not self.nodeid and self.form is None:
+ return '[Link: not called from item]'
+ propclass = self.properties[property]
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ if propclass.isMultilinkType: value = []
+ else: value = ''
+ if propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ linkvalue = linkcl.get(value, k)
+ return '<a href="%s%s">%s</a>'%(linkcl, value, linkvalue)
+ if propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = []
+ for value in value:
+ linkvalue = linkcl.get(value, k)
+ l.append('<a href="%s%s">%s</a>'%(linkcl, value, linkvalue))
+ return ', '.join(l)
+ return '<a href="%s%s">%s</a>'%(self.classname, self.nodeid, value)
+
+class Count(Base):
+ ''' for a Multilink property, display a count of the number of links in
+ the list
+ '''
+ def __call__(self, property, **args):
+ if not self.nodeid:
+ return '[Count: not called from item]'
+ propclass = self.properties[property]
+ value = self.cl.get(self.nodeid, property)
+ if propclass.isMultilinkType:
+ return str(len(value))
+ return '[Count: not a Multilink]'
+
+# XXX pretty is definitely new ;)
+class Reldate(Base):
+ ''' display a Date property in terms of an interval relative to the
+ current date (e.g. "+ 3w", "- 2d").
+
+ with the 'pretty' flag, make it pretty
+ '''
+ def __call__(self, property, pretty=0):
+ if not self.nodeid and self.form is None:
+ return '[Reldate: not called from item]'
+ propclass = self.properties[property]
+ if not propclass.isDateType:
+ return '[Reldate: not a Date]'
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ value = date.Date('.')
+ interval = value - date.Date('.')
+ if pretty:
+ if not self.nodeid:
+ return 'now'
+ pretty = interval.pretty()
+ if pretty is None:
+ pretty = value.pretty()
+ return pretty
+ return str(interval)
+
+class Download(Base):
+ ''' show a Link("file") or Multilink("file") property using links that
+ allow you to download files
+ '''
+ def __call__(self, property, **args):
+ if not self.nodeid:
+ return '[Download: not called from item]'
+ propclass = self.properties[property]
+ value = self.cl.get(self.nodeid, property)
+ if propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ linkvalue = linkcl.get(value, k)
+ return '<a href="%s%s">%s</a>'%(linkcl, value, linkvalue)
+ if propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = []
+ for value in value:
+ linkvalue = linkcl.get(value, k)
+ l.append('<a href="%s%s">%s</a>'%(linkcl, value, linkvalue))
+ return ', '.join(l)
+ return '[Download: not a link]'
+
+
+class Checklist(Base):
+ ''' for a Link or Multilink property, display checkboxes for the available
+ choices to permit filtering
+ '''
+ def __call__(self, property, **args):
+ propclass = self.properties[property]
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ value = []
+ if propclass.isLinkType or propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = []
+ k = linkcl.getkey()
+ for optionid in linkcl.list():
+ option = linkcl.get(optionid, k)
+ if optionid in value:
+ checked = 'checked'
+ else:
+ checked = ''
+ l.append('%s:<input type="checkbox" %s name="%s" value="%s">'%(
+ option, checked, propclass.classname, option))
+ return '\n'.join(l)
+ return '[Checklist: not a link]'
+
+class Note(Base):
+ ''' display a "note" field, which is a text area for entering a note to
+ go along with a change.
+ '''
+ def __call__(self, rows=5, cols=80):
+ # TODO: pull the value from the form
+ return '<textarea name="__note" rows=%s cols=%s></textarea>'%(rows,
+ cols)
+
+# XXX new function
+class List(Base):
+ ''' list the items specified by property using the standard index for
+ the class
+ '''
+ def __call__(self, property, **args):
+ propclass = self.properties[property]
+ if not propclass.isMultilinkType:
+ return '[List: not a Multilink]'
+ fp = StringIO.StringIO()
+ args['show_display_form'] = 0
+ value = self.cl.get(self.nodeid, property)
+ index(fp, self.db, propclass.classname, nodeids=value,
+ show_display_form=0)
+ return fp.getvalue()
+
+# XXX new function
+class History(Base):
+ ''' list the history of the item
+ '''
+ def __call__(self, **args):
+ l = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
+ '<tr class="list-header">',
+ '<td><span class="list-item"><strong>Date</strong></span></td>',
+ '<td><span class="list-item"><strong>User</strong></span></td>',
+ '<td><span class="list-item"><strong>Action</strong></span></td>',
+ '<td><span class="list-item"><strong>Args</strong></span></td>']
+
+ for id, date, user, action, args in self.cl.history(self.nodeid):
+ l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
+ date, user, action, args))
+ l.append('</table>')
+ return '\n'.join(l)
+
+# XXX new function
+class Submit(Base):
+ ''' add a submit button for the item
+ '''
+ def __call__(self):
+ if self.nodeid:
+ return '<input type="submit" value="Submit Changes">'
+ elif self.form is not None:
+ return '<input type="submit" value="Submit New Entry">'
+ else:
+ return '[Submit: not called from item]'
+
+
+#
+# INDEX TEMPLATES
+#
+class IndexTemplateReplace:
+ def __init__(self, globals, locals, props):
+ self.globals = globals
+ self.locals = locals
+ self.props = props
+
+ def go(self, text, replace=re.compile(
+ r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
+ r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)):
+ return replace.sub(self, text)
+
+ def __call__(self, m, filter=None, columns=None, sort=None, group=None):
+ if m.group('name'):
+ if m.group('name') in self.props:
+ text = m.group('text')
+ replace = IndexTemplateReplace(self.globals, {}, self.props)
+ return replace.go(m.group('text'))
+ else:
+ return ''
+ if m.group('display'):
+ command = m.group('command')
+ return eval(command, self.globals, self.locals)
+ print '*** unhandled match', m.groupdict()
+
+def sortby(sort_name, columns, filter, sort, group, filterspec):
+ l = []
+ w = l.append
+ for k, v in filterspec.items():
+ k = urllib.quote(k)
+ if type(v) == type([]):
+ w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
+ else:
+ w('%s=%s'%(k, urllib.quote(v)))
+ if columns:
+ w(':columns=%s'%','.join(map(urllib.quote, columns)))
+ if filter:
+ w(':filter=%s'%','.join(map(urllib.quote, filter)))
+ if group:
+ w(':group=%s'%','.join(map(urllib.quote, group)))
+ m = []
+ s_dir = ''
+ for name in sort:
+ dir = name[0]
+ if dir == '-':
+ dir = ''
+ else:
+ name = name[1:]
+ if sort_name == name:
+ if dir == '':
+ s_dir = '-'
+ elif dir == '-':
+ s_dir = ''
+ else:
+ m.append(dir+urllib.quote(name))
+ m.insert(0, s_dir+urllib.quote(sort_name))
+ # so things don't get completely out of hand, limit the sort to two columns
+ w(':sort=%s'%','.join(m[:2]))
+ return '&'.join(l)
+
+def index(fp, db, classname, filterspec={}, filter=[], columns=[], sort=[],
+ group=[], show_display_form=1, nodeids=None,
+ col_re=re.compile(r'<property\s+name="([^>]+)">')):
+
+ globals = {
+ 'plain': Plain(db, classname, form={}),
+ 'field': Field(db, classname, form={}),
+ 'menu': Menu(db, classname, form={}),
+ 'link': Link(db, classname, form={}),
+ 'count': Count(db, classname, form={}),
+ 'reldate': Reldate(db, classname, form={}),
+ 'download': Download(db, classname, form={}),
+ 'checklist': Checklist(db, classname, form={}),
+ 'list': List(db, classname, form={}),
+ 'history': History(db, classname, form={}),
+ 'submit': Submit(db, classname, form={}),
+ 'note': Note(db, classname, form={})
+ }
+ cl = db.classes[classname]
+ properties = cl.getprops()
+ w = fp.write
+
+ try:
+ template = open(os.path.join('templates', classname+'.filter')).read()
+ all_filters = col_re.findall(template)
+ except IOError, error:
+ if error.errno != 2: raise
+ template = None
+ all_filters = []
+ if template and filter:
+ # display the filter section
+ w('<form>')
+ w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+ w('<tr class="location-bar">')
+ w(' <th align="left" colspan="2">Filter specification...</th>')
+ w('</tr>')
+ replace = IndexTemplateReplace(globals, locals(), filter)
+ w(replace.go(template))
+ if columns:
+ w('<input type="hidden" name=":columns" value="%s">'%','.join(columns))
+ if filter:
+ w('<input type="hidden" name=":filter" value="%s">'%','.join(filter))
+ if sort:
+ w('<input type="hidden" name=":sort" value="%s">'%','.join(sort))
+ if group:
+ w('<input type="hidden" name=":group" value="%s">'%','.join(group))
+ for k, v in filterspec.items():
+ if type(v) == type([]): v = ','.join(v)
+ w('<input type="hidden" name="%s" value="%s">'%(k, v))
+ w('<tr class="location-bar"><td width="1%%"> </td>')
+ w('<td><input type="submit" value="Redisplay"></td></tr>')
+ w('</table>')
+ w('</form>')
+
+ # XXX deviate from spec here ...
+ # load the index section template and figure the default columns from it
+ template = open(os.path.join('templates', classname+'.index')).read()
+ all_columns = col_re.findall(template)
+ if not columns:
+ columns = []
+ for name in all_columns:
+ columns.append(name)
+ else:
+ # re-sort columns to be the same order as all_columns
+ l = []
+ for name in all_columns:
+ if name in columns:
+ l.append(name)
+ columns = l
+
+ # now display the index section
+ w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+ w('<tr class="list-header">')
+ for name in columns:
+ cname = name.capitalize()
+ if show_display_form:
+ anchor = "%s?%s"%(classname, sortby(name, columns, filter,
+ sort, group, filterspec))
+ w('<td><span class="list-item"><a href="%s">%s</a></span></td>'%(
+ anchor, cname))
+ else:
+ w('<td><span class="list-item">%s</span></td>'%cname)
+ w('</tr>')
+
+ # this stuff is used for group headings - optimise the group names
+ old_group = None
+ group_names = []
+ if group:
+ for name in group:
+ if name[0] == '-': group_names.append(name[1:])
+ else: group_names.append(name)
+
+ # now actually loop through all the nodes we get from the filter and
+ # apply the template
+ if nodeids is None:
+ nodeids = cl.filter(filterspec, sort, group)
+ for nodeid in nodeids:
+ # check for a group heading
+ if group_names:
+ this_group = [cl.get(nodeid, name) for name in group_names]
+ if this_group != old_group:
+ l = []
+ for name in group_names:
+ prop = properties[name]
+ if prop.isLinkType:
+ group_cl = db.classes[prop.classname]
+ key = group_cl.getkey()
+ value = cl.get(nodeid, name)
+ if value is None:
+ l.append('[unselected %s]'%prop.classname)
+ else:
+ l.append(group_cl.get(cl.get(nodeid, name), key))
+ elif prop.isMultilinkType:
+ group_cl = db.classes[prop.classname]
+ key = group_cl.getkey()
+ for value in cl.get(nodeid, name):
+ l.append(group_cl.get(value, key))
+ else:
+ l.append(cl.get(nodeid, name))
+ w('<tr class="list-header">'
+ '<td align=left colspan=%s><strong>%s</strong></td></tr>'%(
+ len(columns), ', '.join(l)))
+ old_group = this_group
+
+ # display this node's row
+ for value in globals.values():
+ if hasattr(value, 'nodeid'):
+ value.nodeid = nodeid
+ replace = IndexTemplateReplace(globals, locals(), columns)
+ w(replace.go(template))
+
+ w('</table>')
+
+ if not show_display_form:
+ return
+
+ # now add in the filter/columns/group/etc config table form
+ w('<p><form>')
+ w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+ for k,v in filterspec.items():
+ if type(v) == type([]): v = ','.join(v)
+ w('<input type="hidden" name="%s" value="%s">'%(k, v))
+ if sort:
+ w('<input type="hidden" name=":sort" value="%s">'%','.join(sort))
+ names = []
+ for name in cl.getprops().keys():
+ if name in all_filters or name in all_columns:
+ names.append(name)
+ w('<tr class="location-bar">')
+ w('<th align="left" colspan=%s>View customisation...</th></tr>'%
+ (len(names)+1))
+ w('<tr class="location-bar"><th> </th>')
+ for name in names:
+ w('<th>%s</th>'%name.capitalize())
+ w('</tr>')
+
+ # filter
+ if all_filters:
+ w('<tr><th width="1%" align=right class="location-bar">Filters</th>')
+ for name in names:
+ if name not in all_filters:
+ w('<td> </td>')
+ continue
+ if name in filter: checked=' checked'
+ else: checked=''
+ w('<td align=middle>')
+ w('<input type="checkbox" name=":filter" value="%s" %s></td>'%(name,
+ checked))
+ w('</tr>')
+
+ # columns
+ if all_columns:
+ w('<tr><th width="1%" align=right class="location-bar">Columns</th>')
+ for name in names:
+ if name not in all_columns:
+ w('<td> </td>')
+ continue
+ if name in columns: checked=' checked'
+ else: checked=''
+ w('<td align=middle>')
+ w('<input type="checkbox" name=":columns" value="%s" %s></td>'%(
+ name, checked))
+ w('</tr>')
+
+ # group
+ w('<tr><th width="1%" align=right class="location-bar">Grouping</th>')
+ for name in names:
+ prop = properties[name]
+ if name not in all_columns:
+ w('<td> </td>')
+ continue
+ if name in group: checked=' checked'
+ else: checked=''
+ w('<td align=middle>')
+ w('<input type="checkbox" name=":group" value="%s" %s></td>'%(
+ name, checked))
+ w('</tr>')
+
+ w('<tr class="location-bar"><td width="1%"> </td>')
+ w('<td colspan="%s">'%len(names))
+ w('<input type="submit" value="Redisplay"></td></tr>')
+ w('</table>')
+ w('</form>')
+
+
+#
+# ITEM TEMPLATES
+#
+class ItemTemplateReplace:
+ def __init__(self, globals, locals, cl, nodeid):
+ self.globals = globals
+ self.locals = locals
+ self.cl = cl
+ self.nodeid = nodeid
+
+ def go(self, text, replace=re.compile(
+ r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
+ r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)):
+ return replace.sub(self, text)
+
+ def __call__(self, m, filter=None, columns=None, sort=None, group=None):
+ if m.group('name'):
+ if self.nodeid and self.cl.get(self.nodeid, m.group('name')):
+ replace = ItemTemplateReplace(self.globals, {}, self.cl,
+ self.nodeid)
+ return replace.go(m.group('text'))
+ else:
+ return ''
+ if m.group('display'):
+ command = m.group('command')
+ return eval(command, self.globals, self.locals)
+ print '*** unhandled match', m.groupdict()
+
+def item(fp, db, classname, nodeid, replace=re.compile(
+ r'((?P<prop><property\s+name="(?P<propname>[^>]+)">)|'
+ r'(?P<endprop></property>)|'
+ r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I)):
+
+ globals = {
+ 'plain': Plain(db, classname, nodeid),
+ 'field': Field(db, classname, nodeid),
+ 'menu': Menu(db, classname, nodeid),
+ 'link': Link(db, classname, nodeid),
+ 'count': Count(db, classname, nodeid),
+ 'reldate': Reldate(db, classname, nodeid),
+ 'download': Download(db, classname, nodeid),
+ 'checklist': Checklist(db, classname, nodeid),
+ 'list': List(db, classname, nodeid),
+ 'history': History(db, classname, nodeid),
+ 'submit': Submit(db, classname, nodeid),
+ 'note': Note(db, classname, nodeid)
+ }
+
+ cl = db.classes[classname]
+ properties = cl.getprops()
+
+ if properties.has_key('type') and properties.has_key('content'):
+ pass
+ # XXX we really want to return this as a downloadable...
+ # currently I handle this at a higher level by detecting 'file'
+ # designators...
+
+ w = fp.write
+ w('<form action="%s%s">'%(classname, nodeid))
+ s = open(os.path.join('templates', classname+'.item')).read()
+ replace = ItemTemplateReplace(globals, locals(), cl, nodeid)
+ w(replace.go(s))
+ w('</form>')
+
+
+def newitem(fp, db, classname, form, replace=re.compile(
+ r'((?P<prop><property\s+name="(?P<propname>[^>]+)">)|'
+ r'(?P<endprop></property>)|'
+ r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I)):
+ globals = {
+ 'plain': Plain(db, classname, form=form),
+ 'field': Field(db, classname, form=form),
+ 'menu': Menu(db, classname, form=form),
+ 'link': Link(db, classname, form=form),
+ 'count': Count(db, classname, form=form),
+ 'reldate': Reldate(db, classname, form=form),
+ 'download': Download(db, classname, form=form),
+ 'checklist': Checklist(db, classname, form=form),
+ 'list': List(db, classname, form=form),
+ 'history': History(db, classname, form=form),
+ 'submit': Submit(db, classname, form=form),
+ 'note': Note(db, classname, form=form)
+ }
+
+ cl = db.classes[classname]
+ properties = cl.getprops()
+
+ w = fp.write
+ try:
+ s = open(os.path.join('templates', classname+'.newitem')).read()
+ except:
+ s = open(os.path.join('templates', classname+'.item')).read()
+ w('<form action="new%s">'%classname)
+ replace = ItemTemplateReplace(globals, locals(), None, None)
+ w(replace.go(s))
+ w('</form>')
+
diff --git a/templates/file.index b/templates/file.index
--- /dev/null
+++ b/templates/file.index
@@ -0,0 +1,8 @@
+<tr>
+ <property name="name">
+ <td><display call="link('name')"></td>
+ </property>
+ <property name="type">
+ <td><display call="plain('type')"></td>
+ </property>
+</tr>
diff --git a/templates/issue.filter b/templates/issue.filter
--- /dev/null
+++ b/templates/issue.filter
@@ -0,0 +1,36 @@
+<property name="title">
+ <tr><th width="1%" align="right" class="location-bar">Title</th>
+ <td><display call="field('title')"></td></tr>
+</property>
+<property name="status">
+ <tr><th width="1%" align="right" class="location-bar">Status</th>
+ <td><display call="checklist('status')"></td></tr>
+</property>
+<property name="priority">
+ <tr><th width="1%" align="right" class="location-bar">Priority</th>
+ <td><display call="checklist('priority')"></td></tr>
+</property>
+<property name="platform">
+ <tr><th width="1%" align="right" class="location-bar">Platform</th>
+ <td><display call="checklist('platform')"></td></tr>
+</property>
+<property name="product">
+ <tr><th width="1%" align="right" class="location-bar">Product</th>
+ <td><display call="checklist('product')"></td></tr>
+</property>
+<property name="version">
+ <tr><th width="1%" align="right" class="location-bar">Version</th>
+ <td><display call="field('version')"></td></tr>
+</property>
+<property name="source">
+ <tr><th width="1%" align="right" class="location-bar">Source</th>
+ <td><display call="checklist('source')"></td></tr>
+</property>
+<property name="assignedto">
+ <tr><th width="1%" align="right" class="location-bar">Assigned to</th>
+ <td><display call="checklist('assignedto')"></td></tr>
+</property>
+<property name="customername">
+ <tr><th width="1%" align="right" class="location-bar">Customer name</th>
+ <td><display call="field('customername')"></td></tr>
+</property>
diff --git a/templates/issue.index b/templates/issue.index
--- /dev/null
+++ b/templates/issue.index
@@ -0,0 +1,32 @@
+<tr>
+ <property name="activity">
+ <td valign="top"><display call="reldate('activity', pretty=1)"></td>
+ </property>
+ <property name="priority">
+ <td valign="top"><display call="plain('priority')"></td>
+ </property>
+ <property name="status">
+ <td valign="top"><display call="plain('status')"></td>
+ </property>
+ <property name="title">
+ <td valign="top"><display call="link('title')"></td>
+ </property>
+ <property name="platform">
+ <td valign="top"><display call="plain('platform')"></td>
+ </property>
+ <property name="product">
+ <td valign="top"><display call="plain('product')"></td>
+ </property>
+ <property name="version">
+ <td valign="top"><display call="plain('version')"></td>
+ </property>
+ <property name="source">
+ <td valign="top"><display call="plain('source')"></td>
+ </property>
+ <property name="assignedto">
+ <td valign="top"><display call="plain('assignedto')"></td>
+ </property>
+ <property name="customername">
+ <td valign="top"><display call="plain('customername')"></td>
+ </property>
+</tr>
diff --git a/templates/issue.item b/templates/issue.item
--- /dev/null
+++ b/templates/issue.item
@@ -0,0 +1,85 @@
+<table border=0 cellspacing=0 cellpadding=2>
+
+<tr class="strong-header">
+ <td colspan=4>Item Information</td>
+</td>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Title</span></td>
+ <td colspan=3 class="form-text"><display call="field('title', size=80)"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Product</span></td>
+ <td class="form-text" valign=middle><display call="menu('product')">
+ version:<display call="field('version', 5)"></td>
+ <td width=1% nowrap align=right><span class="form-label">Platform</span></td>
+ <td class="form-text" valign=middle><display call="checklist('platform')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Created</span></td>
+ <td class="form-text"><display call="reldate('creation', pretty=1)">
+ (<display call="plain('creator')">)</td>
+ <td width=1% nowrap align=right><span class="form-label">Last activity</span></td>
+ <td class="form-text"><display call="reldate('activity', pretty=1)"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Priority</span></td>
+ <td class="form-text"><display call="field('priority')"></td>
+ <td width=1% nowrap align=right><span class="form-label">Source</span></td>
+ <td class="form-text"><display call="field('source')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Status</span></td>
+ <td class="form-text"><display call="menu('status')"></td>
+ <td width=1% nowrap align=right><span class="form-label">Rate</span></td>
+ <td class="form-text"><display call="field('rate')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Assigned To</span></td>
+ <td class="form-text"><display call="field('assignedto')"></td>
+ <td width=1% nowrap align=right><span class="form-label">Customer Name</span></td>
+ <td class="form-text"><display call="field('customername')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Superseder</span></td>
+ <td class="form-text"><display call="field('superseder', size=40, showid=1)"></td>
+ <td width=1% nowrap align=right><span class="form-label">Nosy List</span></td>
+ <td class="form-text"><display call="field('nosy')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Change Note</span></td>
+ <td colspan=3 class="form-text"><display call="note()"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td> </td>
+ <td colspan=3 class="form-text"><display call="submit()"></td>
+</tr>
+
+<property name="messages">
+<tr class="strong-header">
+ <td colspan=4><b>Messages</b></td>
+</tr>
+<tr>
+ <td colspan=4><display call="list('messages')"></td>
+</tr>
+</property>
+
+<property name="files">
+ <tr class="strong-header">
+ <td colspan=4><b>Files</b></td>
+ </tr>
+ <tr>
+ <td colspan=4><display call="list('files')"></td>
+ </tr>
+</property>
+
+</table>
+
diff --git a/templates/msg.index b/templates/msg.index
--- /dev/null
+++ b/templates/msg.index
@@ -0,0 +1,11 @@
+<tr>
+ <property name="date">
+ <td><display call="link('date')"></td>
+ </property>
+ <property name="author">
+ <td><display call="plain('author')"></td>
+ </property>
+ <property name="summary">
+ <td><display call="plain('summary')"></td>
+ </property>
+</tr>
diff --git a/templates/msg.item b/templates/msg.item
--- /dev/null
+++ b/templates/msg.item
@@ -0,0 +1,36 @@
+<table border=0 cellspacing=0 cellpadding=2>
+
+<tr class="strong-header">
+ <td colspan=2>Message Information</td>
+</td>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Author</span></td>
+ <td class="form-text"><display call="plain('author')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Recipients</span></td>
+ <td class="form-text"><display call="plain('recipients')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Date</span></td>
+ <td class="form-text"><display call="plain('date')"></td>
+</tr>
+
+<tr bgcolor="ffeaff">
+ <td colspan=2 class="form-text">
+ <pre><display call="plain('content')"></pre>
+ </td>
+</tr>
+
+<property name="files">
+<tr class="strong-header"><td colspan=2><b>Files</b></td></tr>
+<tr><td colspan=2><display call="list('files')"></td></tr>
+</property>
+
+<tr class="strong-header"><td colspan=2><b>History</b></td><tr>
+<tr><td colspan=2><display call="history()"></td></tr>
+
+</table>
diff --git a/templates/user.index b/templates/user.index
--- /dev/null
+++ b/templates/user.index
@@ -0,0 +1,17 @@
+<tr>
+ <property name="username">
+ <td><display call="link('username')"></td>
+ </property>
+ <property name="realname">
+ <td><display call="plain('realname')"></td>
+ </property>
+ <property name="organisation">
+ <td><display call="plain('organisation')"></td>
+ </property>
+ <property name="address">
+ <td><display call="plain('address')"></td>
+ </property>
+ <property name="phone">
+ <td><display call="plain('phone')"></td>
+ </property>
+</tr>
diff --git a/templates/user.item b/templates/user.item
--- /dev/null
+++ b/templates/user.item
@@ -0,0 +1,45 @@
+<table border=0 cellspacing=0 cellpadding=2>
+
+<tr class="strong-header">
+ <td colspan=2>Your Details</td>
+</td>
+
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Name</span></td>
+ <td class="form-text"><display call="field('realname', size=40)"></td>
+</tr>
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Login Name</span></td>
+ <td class="form-text"><display call="field('username', size=40)"></td>
+</tr>
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Login Password</span></td>
+ <td class="form-text"><display call="field('password', size=10)"></td>
+</tr>
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Phone</span></td>
+ <td class="form-text"><display call="field('phone', size=40)"></td>
+</tr>
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">Organisation</span></td>
+ <td class="form-text"><display call="field('organisation', size=40)"></td>
+</tr>
+<tr bgcolor="ffffea">
+ <td width=1% nowrap align=right><span class="form-label">E-mail address</span></td>
+ <td class="form-text"><display call="field('address', size=40)"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+ <td> </td>
+ <td class="form-text"><display call="submit()"></td>
+</tr>
+
+<tr class="strong-header">
+ <td colspan=2><b>History</b></td>
+</tr>
+<tr>
+ <td colspan=2><display call="history()"></td>
+</tr>
+
+</table>
+