Mercurial > p > roundup > code
changeset 0:5e92642cd1f8
Initial import of code
Currently version 1.0.2 but with the 1.0.3 changes as given in the
CHANGES file. Is about ready for a 1.0.3 release.
[[This repository is actually a lift into git made in October 2011 of
code history originally kept in CVS and later in Subversion. This
import marks the point at which the original private CVS moved to
public CVS on SourceForge.
Due to technical problems with CVS and the CVS to Subversion
conversion tools, portions of the early history have somewhat garbled
metadata. Here and elsewhere in this repo, comments enclosed in curly
braces were added at the time of the git lift in an attempt to document
the problem.]]
| author | Richard Jones <richard@users.sourceforge.net> |
|---|---|
| date | Thu, 19 Jul 2001 02:16:19 +0000 |
| parents | |
| children | fddc8af9f541 |
| files | CHANGES README cgitb.py config.py date.py hyperdb.py roundup-mailgw.py roundup.cgi roundup.py roundup_cgi.py roundupdb.py server.py style.css template.py templates/file.index templates/issue.filter templates/issue.index templates/issue.item templates/msg.index templates/msg.item templates/user.index templates/user.item |
| diffstat | 22 files changed, 4323 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/CHANGES Thu Jul 19 02:16:19 2001 +0000 @@ -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
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README Thu Jul 19 02:16:19 2001 +0000 @@ -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. +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cgitb.py Thu Jul 19 02:16:19 2001 +0000 @@ -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() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/config.py Thu Jul 19 02:16:19 2001 +0000 @@ -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'
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/date.py Thu Jul 19 02:16:19 2001 +0000 @@ -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() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hyperdb.py Thu Jul 19 02:16:19 2001 +0000 @@ -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 +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/roundup-mailgw.py Thu Jul 19 02:16:19 2001 +0000 @@ -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() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/roundup.cgi Thu Jul 19 02:16:19 2001 +0000 @@ -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
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/roundup.py Thu Jul 19 02:16:19 2001 +0000 @@ -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()) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/roundup_cgi.py Thu Jul 19 02:16:19 2001 +0000 @@ -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() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/roundupdb.py Thu Jul 19 02:16:19 2001 +0000 @@ -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() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/server.py Thu Jul 19 02:16:19 2001 +0000 @@ -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() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/style.css Thu Jul 19 02:16:19 2001 +0000 @@ -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; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/template.py Thu Jul 19 02:16:19 2001 +0000 @@ -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>') +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/file.index Thu Jul 19 02:16:19 2001 +0000 @@ -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>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/issue.filter Thu Jul 19 02:16:19 2001 +0000 @@ -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>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/issue.index Thu Jul 19 02:16:19 2001 +0000 @@ -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>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/issue.item Thu Jul 19 02:16:19 2001 +0000 @@ -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> +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/msg.index Thu Jul 19 02:16:19 2001 +0000 @@ -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>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/msg.item Thu Jul 19 02:16:19 2001 +0000 @@ -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>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/user.index Thu Jul 19 02:16:19 2001 +0000 @@ -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>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/user.item Thu Jul 19 02:16:19 2001 +0000 @@ -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> +
