From: richard Date: Thu, 19 Jul 2001 02:16:19 +0000 (+0000) Subject: This commit was generated by cvs2svn to compensate for changes in r2, X-Git-Url: https://git.tokkee.org/?a=commitdiff_plain;h=86426feb223d314fc5062874fe1be938e978eb0d;p=roundup.git This commit was generated by cvs2svn to compensate for changes in r2, which included commits to RCS files with non-trunk default branches. git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@3 57a73879-2fb5-44c3-a270-3262357dd7e2 --- diff --git a/CHANGES b/CHANGES new file mode 100644 index 0000000..9a35d1c --- /dev/null +++ b/CHANGES @@ -0,0 +1,45 @@ +2001-07-11 - 0.1.0 + . Needed a bug tracking system. Looked around. Tried to install many + Perl-based systems, to no avail. Got tired of waiting for Roundup to be + released. Had just finished major product project, so needed something + different for a while. Roundup here I come... + + +2001-07-18 - 0.1.1 + . Initial version release with consent of Roundup spec author, Ka-Ping Yee: + "Amazing! Nice work. I'll watch for the source code on your website." + + +2001-07-18 - 0.1.2 + . Set default index to ?:group=priority&:columns=activity,status,title so + the priority column isn't displayed. + . Thanks Anthony: + . added notes to the README about Python prerequisites + . added check to roundup.py, roundup.cgi, server.py and roundup-mailgw.py + for python 2+ - and made the file itself parseable by 1.5.2 ;) + . python 2.0 didn't have the default args for the time module functions. + . better handling of db directory in initDB + . Sorting on the extra properties defined by roundupdb classes was broken + due to the caching used. May now sort on activity and creation + properties, etc. + . Set the default index to sort on activity + +2001-07-XX - 0.1.3 + . Reldate now takes an argument "pretty" - when true, it pretty-prints the + interval generated up to 5 days, then pretty-prints the date of last + activity. The issue index and item now use the pretty format. + . Classes list for admin user in CGI interface. + . Made the view configuration more accessible, neater and more realistic. + . Fixed list view grouping handling grouping by a Multilink or String or Link + value of None or Date, ... (mind you, sorting by Date???) + . Fixed bug in the plain formatter when a Link was None. + . Fixed ordering of list view column headings. + . Fixed list view column heading sort links - and limited the number of + columns to sort by to 2. + . Added searching by glob to StringType filtering - + ^text - search for text at start of fields + text$ - search for text at end of fields + ^text$ - exactly match text in fields + te*xt - search for text matching "te""xt" + te?xt - search for text matching "te""xt" + . Added more fields to the issue.filter and issue.index templates diff --git a/README b/README new file mode 100644 index 0000000..398bcae --- /dev/null +++ b/README @@ -0,0 +1,218 @@ + Roundup + ======= + + +1. License +========== +This software is released under the GNU GPL. The copyright is held by Bizar +Software Pty Ltd (http://www.bizarsoftware.com.au). + +The stylesheet included with this package has been copied from the Zope +management interface and presumably belongs to Digital Creations. + + + +2. Installation +=============== +These instructions work on redhat 6.2 and mandrake 8.0 - with the caveat +that these systems don't come with python 2.0 or newer installed, so you'll +have to upgrade python before this stuff will work. + +Note that most of the following is configurable in the config.py, it's just +not documented. At a minimum, you'll want to change the email addresses and +mail host specification in the config. + + +2.0 Prerequisites +----------------- +Either: + . Python 2.0 with pydoc installed. See http://www.lfw.org/ for pydoc. +or + . Python 2.1 + +Both need the bsddb module. + + +2.1 Initial Setup +----------------- + 1. Make a directory in /home/httpd/html called 'roundup'. + 2. Copy the tar file's contents there. + 3. "python roundup.py init" to initialise the database (by default, it + goes in a directory called 'db' in the current directory). Choose a + sensible admin password. + 4. "chmod -R a+rw db" + + +2.2 Mail +-------- +Set up a mail alias called "issue_tracker" as: + "|/usr/bin/python /home/httpd/html/roundup/roundup-mailgw.py" + +In some installations (e.g. RedHat 6.2 I think) you'll need to set up smrsh +so sendmail will accept the pipe command. In that case, symlink +/etc/smrsh/python to /usr/bin/python and change the command to: + "|python /home/httpd/html/roundup/roundup-mailgw.py" + + +2.3 Web Interface +----------------- +This software will work through apache or stand-alone. + +Stand-alone: + 1. Edit server.py at the bottom to set your hostname and a port that is free. + 2. "python server.py" + 3. Load up the page "/" using the port number you set. + +Apache: + 1. Make sure roundup.cgi is executable + 2. Edit your /etc/httpd/conf/httpd.conf and make sure that the + /home/httpd/html/roundup/roundup.cgi script will be treated as a CGI + script. + 3. Add the following to your /etc/httpd/conf/httpd.conf: +snip >>> +RewriteEngine on +RewriteCond %{HTTP:Authorization} ^(.*) +RewriteRule ^/roundup/roundup.cgi(.*) /home/httpd/html/roundup/roundup.cgi$1 [e=HTTP_CGI_AUTHORIZATION:%1,t=application/x-httpd-cgi,l] +<<< snip + note: the RewriteRule must be on one line - no breaks + 4. Re-start your apache to re-load the config + 5. Load up the page "/roundup/roundup.cgi/" + + +3. Usage +======== +The system is designed to accessed through the command-line, e-mail or web +interface. + +3.1 Command-line +---------------- +The command-line tool is called "roundup.py" and is used for most low-level +database manipulations such as: + . redefining the list of products ("create" and "retire" commands) + . adding users manually, or setting their passwords ("create" and "set") + . other stuff - run it with no arguments to get a better description of + what it does. + + +3.2 E-mail +---------- +See the docstring at the start of the roundup-mailgw.py source file. + + +3.3 Web +------- +Hopefully, this interface is pretty self-explanatory... + +Index views may be modified by the following arguments: + :sort - sort by prop name, optionally preceeded with '-' + to give descending or nothing for ascending sorting. + :group - group by prop name, optionally preceeded with '-' or + to sort in descending or nothing for ascending order. + :filter - selects which props should be displayed in the filter + section. Default is all. + :columns - selects the columns that should be displayed. + Default is all. + propname - selects the values the node properties given by propname + must have (very basic search/filter). + + + +3. Design +========= +This software was written according to the specification found at + http://software-carpentry.codesourcery.com/entries/second-round/track/Roundup/ + +... with some modifications. I've marked these in the source with 'XXX' +comments when I remember to. + +In short: + Class.find() - may match multiple properties, uses keyword args. + + Class.filter() - isn't in the spec and it's very useful to have at the Class + level. + + CGI interface index view specifier layout part - lose the '+' from the + sorting arguments (it's a reserved URL character ;). Just made no + prefix mean ascending and '-' prefix descending. + + ItemClass - renamed to IssueClass to better match it only having one + hypderdb class "issue". Allowing > 1 hyperdb class breaks the + "superseder" multilink (since it can only link to one thing, and we'd + want bugs to link to support and vice-versa). + + templates - the call="link()" is handled by special-case mechanisms in my + top-level CGI handler. In a nutshell, the handler looks for a method on + itself called 'index%s' or 'item%s' where %s is a class. Most items + pass on to the templating mechanism, but the file class _always_ does + downloading. It'll probably stay this way too... + + template - call="link(property)" may be used to link "the current node" + (from an index) - the link text is the property specified. + + template - added functions that I found very useful: List, History and + Submit. + + template - items must specify the message lists, history, etc. Having them + by default was sometimes not wanted. + + template - index view determines its default columns from the template's + tags. + + template - menu() and field() look awfully similar now .... ;) + + roundup.py - the command-line tool has a lot more commands at its disposal + + +4. TODO +======= +Most of the TODO items are captured in comments in the code. In summary: + +in general: + . better error handling (nicer messages for users) + . possibly revert the entire damn thing to 1.5.2 ... :( +hyperdb: + . transaction support +roundupdb: + . split the file storage into multiple files +roundup-mailgw: + . errors as attachments + . snip signatures? +server: + . check the source file timestamps before reloading +date: + . blue Date.__sub__ needs food, badly +config + . default to blank config in distribution and warn appropriately +roundup_cgi + . searching + . keep form fields in form on bad submission - only clear it if all ok + + + +5. Known Bugs +============= + +http://dirk.adroit/roundup/roundup.cgi/issue?%3Acolumns%3Dactivity%2Cstatus%2Ctitle&%3Asort%3Dtitle%2C-activity&%3Agroup%3Dpriority + +date: + . date subtraction doesn't work correctly "if the dates cross leap years, + phases of the moon, ..." + +The software still probably has bugs. Please let me know when you find 'em. +Patches are nice, but there'll probably be a good chance I've changed the +code (there's not much to it ;) so a good description will be appreciated +as well. + + + +6. Author +========= +richard@bizarsoftware.com.au + + +7. Thanks +========= +Well, Ping, of course ;) + +Anthony Baxter, for some good first-release feedback. + diff --git a/cgitb.py b/cgitb.py new file mode 100644 index 0000000..5af5ef6 --- /dev/null +++ b/cgitb.py @@ -0,0 +1,113 @@ +import sys, os, types, string, keyword, linecache, tokenize, inspect, pydoc + +def breaker(): + return ('' + + ' > ' + + '' * 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] + '
' + sys.executable + head = pydoc.html.heading( + '%s: %s'%(str(etype), str(evalue)), + '#ffffff', '#aa55cc', pyver) + + head = head + ('

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 = '%s ' % (' ' * 5) + traceback = [] + for frame, file, lnum, func, lines, index in inspect.trace(context): + if file is None: + link = '<file is None - probably inside eval or exec>' + else: + file = os.path.abspath(file) + link = '%s' % (file, pydoc.html.escape(file)) + args, varargs, varkw, locals = inspect.getargvalues(frame) + if func == '?': + call = '' + else: + call = 'in %s' % func + inspect.formatargvalues( + args, varargs, varkw, locals, + formatvalue=lambda value: '=' + pydoc.html.repr(value)) + + level = ''' + +
%s %s
''' % (link, call) + + if file is None: + traceback.append('

' + 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 = 'undefined' + name = '%s' % name + else: + if frame.f_globals.has_key(name): + value = pydoc.html.repr(frame.f_globals[name]) + else: + value = 'undefined' + name = 'global %s' % name + lvals.append('%s = %s' % (name, value)) + if lvals: + lvals = string.join(lvals, ', ') + lvals = indent + ''' +%s
''' % lvals + else: + lvals = '' + + excerpt = [] + i = lnum - index + for line in lines: + number = ' ' * (5-len(str(i))) + str(i) + number = '%s' % number + line = '%s %s' % (number, pydoc.html.preformat(line)) + if i == lnum: + line = ''' + +
%s
''' % line + excerpt.append('\n' + line) + if i == lnum: + excerpt.append(lvals) + i = i + 1 + traceback.append('

' + level + string.join(excerpt, '\n')) + + traceback.reverse() + + exception = '

%s: %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('
%s%s = %s' % (indent, name, value)) + + return head + string.join(attribs) + string.join(traceback) + '

 

' + +def handler(): + print breaker() + print html() + diff --git a/config.py b/config.py new file mode 100644 index 0000000..1cc5365 --- /dev/null +++ b/config.py @@ -0,0 +1,14 @@ +# This is the directory that the database is going to be stored in +DATABASE = '/home/httpd/html/roundup/db' + +# The email address that mail to roundup should go to +ISSUE_TRACKER_EMAIL = 'issue_tracker@bizarsoftware.com.au' + +# The email address that roundup will complain to if it runs into trouble +ADMIN_EMAIL = "roundup-admin@bizarsoftware.com.au" + +# The SMTP mail host that roundup will use to send mail +MAILHOST = 'goanna.adroit.net' + +# Somewhere for roundup to log stuff internally sent to stdout or stderr +LOG = '/home/httpd/html/roundup/roundup.log' diff --git a/date.py b/date.py new file mode 100644 index 0000000..47b94db --- /dev/null +++ b/date.py @@ -0,0 +1,342 @@ +import time, re, calendar + +class Date: + ''' + As strings, date-and-time stamps are specified with the date in + international standard format (yyyy-mm-dd) joined to the time + (hh:mm:ss) by a period ("."). Dates in this form can be easily compared + and are fairly readable when printed. An example of a valid stamp is + "2000-06-24.13:03:59". We'll call this the "full date format". When + Timestamp objects are printed as strings, they appear in the full date + format with the time always given in GMT. The full date format is + always exactly 19 characters long. + + For user input, some partial forms are also permitted: the whole time + or just the seconds may be omitted; and the whole date may be omitted + or just the year may be omitted. If the time is given, the time is + interpreted in the user's local time zone. The Date constructor takes + care of these conversions. In the following examples, suppose that yyyy + is the current year, mm is the current month, and dd is the current day + of the month; and suppose that the user is on Eastern Standard Time. + + "2000-04-17" means + "01-25" means + "2000-04-17.03:45" means + "08-13.22:13" means + "11-07.09:32:43" means + "14:25" means + "8:47:11" means + "." 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(".") + + >>> _.local(-5) + "2000-06-25.19:34:02" + >>> Date(". + 2d") + + >>> Date("1997-04-17", -5) + + >>> Date("01-25", -5) + + >>> Date("08-13.22:13", -5) + + >>> Date("14:25", -5) + + ''' + 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\d\d\d\d)-)?((?P\d\d)-(?P\d\d))?)? # yyyy-mm-dd + (?P\.)? # . + (((?P\d?\d):(?P\d\d))?(:(?P\d\d))?)? # hh:mm:ss + (?P.+)? # 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 ''%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") + + >>> Date(". + 2d") - Interval("3w") + + ''' + 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[-+])? # + or - + \s* + ((?P\d+\s*)y)? # year + \s* + ((?P\d+\s*)m)? # month + \s* + ((?P\d+\s*)w)? # week + \s* + ((?P\d+\s*)d)? # day + \s* + (((?P\d?\d):(?P\d\d))?(:(?P\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 ''%self.__str__() + + def pretty(self, threshold=('d', 5)): + ''' print up the date date using one of these nice formats.. + < 1 minute + < 15 minutes + < 30 minutes + < 1 hour + < 12 hours + < 1 day + otherwise, return None (so a full date may be displayed) + ''' + if self.year or self.month or self.day > 5: + return None + if self.day > 1: + return '%s days'%self.day + if self.day == 1 or self.hour > 12: + return 'yesterday' + if self.hour > 1: + return '%s hours'%self.hour + if self.hour == 1: + if self.minute < 15: + return 'an hour' + quart = self.minute/15 + if quart == 2: + return '1 1/2 hours' + return '1 %s/4 hours'%quart + if self.minute < 1: + return 'just now' + if self.minute == 1: + return '1 minute' + if self.minute < 15: + return '%s minutes'%self.minute + quart = self.minute/15 + if quart == 2: + return '1/2 an hour' + return '%s/4 hour'%quart + + +def test(): + intervals = (" 3w 1 d 2:00", " + 2d", "3w") + for interval in intervals: + print '>>> Interval("%s")'%interval + print `Interval(interval)` + + dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25", + "08-13.22:13", "14:25") + for date in dates: + print '>>> Date("%s")'%date + print `Date(date)` + + sums = ((". + 2d", "3w"), (".", " 3w 1 d 2:00")) + for date, interval in sums: + print '>>> Date("%s") + Interval("%s")'%(date, interval) + print `Date(date) + Interval(interval)` + +if __name__ == '__main__': + test() + diff --git a/hyperdb.py b/hyperdb.py new file mode 100644 index 0000000..dcd8522 --- /dev/null +++ b/hyperdb.py @@ -0,0 +1,918 @@ +import bsddb, os, cPickle, re, string + +import date +# +# Types +# +class BaseType: + isStringType = 0 + isDateType = 0 + isIntervalType = 0 + isLinkType = 0 + isMultilinkType = 0 + +class String(BaseType): + def __init__(self): + """An object designating a String property.""" + pass + def __repr__(self): + return '<%s>'%self.__class__ + isStringType = 1 + +class Date(BaseType, String): + isDateType = 1 + +class Interval(BaseType, String): + isIntervalType = 1 + +class Link(BaseType): + def __init__(self, classname): + """An object designating a Link property that links to + nodes in a specified class.""" + self.classname = classname + def __repr__(self): + return '<%s to "%s">'%(self.__class__, self.classname) + isLinkType = 1 + +class Multilink(BaseType, Link): + """An object designating a Multilink property that links + to nodes in a specified class. + """ + isMultilinkType = 1 + +class DatabaseError(ValueError): + pass + +# +# Now the database +# +RETIRED_FLAG = '__hyperdb_retired' +class Database: + """A database for storing records containing flexible data types.""" + + def __init__(self, storagelocator, journaltag=None): + """Open a hyperdatabase given a specifier to some storage. + + The meaning of 'storagelocator' depends on the particular + implementation of the hyperdatabase. It could be a file name, + a directory path, a socket descriptor for a connection to a + database over the network, etc. + + The 'journaltag' is a token that will be attached to the journal + entries for any edits done on the database. If 'journaltag' is + None, the database is opened in read-only mode: the Class.create(), + Class.set(), and Class.retire() methods are disabled. + """ + self.dir, self.journaltag = storagelocator, journaltag + self.classes = {} + + # + # Classes + # + def __getattr__(self, classname): + """A convenient way of calling self.getclass(classname).""" + return self.classes[classname] + + def addclass(self, cl): + cn = cl.classname + if self.classes.has_key(cn): + raise ValueError, cn + self.classes[cn] = cl + + def getclasses(self): + """Return a list of the names of all existing classes.""" + l = self.classes.keys() + l.sort() + return l + + def getclass(self, classname): + """Get the Class object representing a particular class. + + If 'classname' is not a valid class name, a KeyError is raised. + """ + return self.classes[classname] + + # + # Class DBs + # + def clear(self): + for cn in self.classes.keys(): + db = os.path.join(self.dir, 'nodes.%s'%cn) + bsddb.btopen(db, 'n') + db = os.path.join(self.dir, 'journals.%s'%cn) + bsddb.btopen(db, 'n') + + def getclassdb(self, classname, mode='r'): + ''' grab a connection to the class db that will be used for + multiple actions + ''' + path = os.path.join(os.getcwd(), self.dir, 'nodes.%s'%classname) + return bsddb.btopen(path, mode) + + def addnode(self, classname, nodeid, node): + ''' add the specified node to its class's db + ''' + db = self.getclassdb(classname, 'c') + db[nodeid] = cPickle.dumps(node, 1) + db.close() + setnode = addnode + + def getnode(self, classname, nodeid, cldb=None): + ''' add the specified node to its class's db + ''' + db = cldb or self.getclassdb(classname) + if not db.has_key(nodeid): + raise IndexError, nodeid + res = cPickle.loads(db[nodeid]) + if not cldb: db.close() + return res + + def hasnode(self, classname, nodeid, cldb=None): + ''' add the specified node to its class's db + ''' + db = cldb or self.getclassdb(classname) + res = db.has_key(nodeid) + if not cldb: db.close() + return res + + def countnodes(self, classname, cldb=None): + db = cldb or self.getclassdb(classname) + return len(db.keys()) + if not cldb: db.close() + return res + + def getnodeids(self, classname, cldb=None): + db = cldb or self.getclassdb(classname) + res = db.keys() + if not cldb: db.close() + return res + + # + # Journal + # + def addjournal(self, classname, nodeid, action, params): + ''' Journal the Action + 'action' may be: + + 'create' or 'set' -- 'params' is a dictionary of property values + 'link' or 'unlink' -- 'params' is (classname, nodeid, propname) + 'retire' -- 'params' is None + ''' + entry = (nodeid, date.Date(), self.journaltag, action, params) + db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'c') + if db.has_key(nodeid): + s = db[nodeid] + l = cPickle.loads(db[nodeid]) + l.append(entry) + else: + l = [entry] + db[nodeid] = cPickle.dumps(l) + db.close() + + def getjournal(self, classname, nodeid): + ''' get the journal for id + ''' + db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'r') + res = cPickle.loads(db[nodeid]) + db.close() + return res + + def close(self): + ''' Close the Database - we must release the circular refs so that + we can be del'ed and the underlying bsddb connections closed + cleanly. + ''' + self.classes = None + + + # + # Basic transaction support + # + # TODO: well, write these methods (and then use them in other code) + def register_action(self): + ''' Register an action to the transaction undo log + ''' + + def commit(self): + ''' Commit the current transaction, start a new one + ''' + + def rollback(self): + ''' Reverse all actions from the current transaction + ''' + + +class Class: + """The handle to a particular class of nodes in a hyperdatabase.""" + + def __init__(self, db, classname, **properties): + """Create a new class with a given name and property specification. + + 'classname' must not collide with the name of an existing class, + or a ValueError is raised. The keyword arguments in 'properties' + must map names to property objects, or a TypeError is raised. + """ + self.classname = classname + self.properties = properties + self.db = db + self.key = '' + + # do the db-related init stuff + db.addclass(self) + + # Editing nodes: + + def create(self, **propvalues): + """Create a new node of this class and return its id. + + The keyword arguments in 'propvalues' map property names to values. + + The values of arguments must be acceptable for the types of their + corresponding properties or a TypeError is raised. + + If this class has a key property, it must be present and its value + must not collide with other key strings or a ValueError is raised. + + Any other properties on this class that are missing from the + 'propvalues' dictionary are set to None. + + If an id in a link or multilink property does not refer to a valid + node, an IndexError is raised. + """ + if self.db.journaltag is None: + raise DatabaseError, 'Database open read-only' + newid = str(self.count() + 1) + + # validate propvalues + num_re = re.compile('^\d+$') + for key, value in propvalues.items(): + if key == self.key: + try: + self.lookup(value) + except KeyError: + pass + else: + raise ValueError, 'node with key "%s" exists'%value + + prop = self.properties[key] + + if prop.isLinkType: + value = str(value) + link_class = self.properties[key].classname + if not num_re.match(value): + try: + value = self.db.classes[link_class].lookup(value) + except: + raise ValueError, 'new property "%s": %s not a %s'%( + key, value, self.properties[key].classname) + propvalues[key] = value + if not self.db.hasnode(link_class, value): + raise ValueError, '%s has no node %s'%(link_class, value) + + # register the link with the newly linked node + self.db.addjournal(link_class, value, 'link', + (self.classname, newid, key)) + + elif prop.isMultilinkType: + if type(value) != type([]): + raise TypeError, 'new property "%s" not a list of ids'%key + link_class = self.properties[key].classname + l = [] + for entry in map(str, value): + if not num_re.match(entry): + try: + entry = self.db.classes[link_class].lookup(entry) + except: + raise ValueError, 'new property "%s": %s not a %s'%( + key, entry, self.properties[key].classname) + l.append(entry) + value = l + propvalues[key] = value + + # handle additions + for id in value: + if not self.db.hasnode(link_class, id): + raise ValueError, '%s has no node %s'%(link_class, id) + # register the link with the newly linked node + self.db.addjournal(link_class, id, 'link', + (self.classname, newid, key)) + + elif prop.isStringType: + if type(value) != type(''): + raise TypeError, 'new property "%s" not a string'%key + + elif prop.isDateType: + if not hasattr(value, 'isDate'): + raise TypeError, 'new property "%s" not a Date'% key + + elif prop.isIntervalType: + if not hasattr(value, 'isInterval'): + raise TypeError, 'new property "%s" not an Interval'% key + + for key,prop in self.properties.items(): + if propvalues.has_key(str(key)): + continue + if prop.isMultilinkType: + propvalues[key] = [] + else: + propvalues[key] = None + + # done + self.db.addnode(self.classname, newid, propvalues) + self.db.addjournal(self.classname, newid, 'create', propvalues) + return newid + + def get(self, nodeid, propname): + """Get the value of a property on an existing node of this class. + + 'nodeid' must be the id of an existing node of this class or an + IndexError is raised. 'propname' must be the name of a property + of this class or a KeyError is raised. + """ + d = self.db.getnode(self.classname, str(nodeid)) + return d[propname] + + # XXX not in spec + def getnode(self, nodeid): + ''' Return a convenience wrapper for the node + ''' + return Node(self, nodeid) + + def set(self, nodeid, **propvalues): + """Modify a property on an existing node of this class. + + 'nodeid' must be the id of an existing node of this class or an + IndexError is raised. + + Each key in 'propvalues' must be the name of a property of this + class or a KeyError is raised. + + All values in 'propvalues' must be acceptable types for their + corresponding properties or a TypeError is raised. + + If the value of the key property is set, it must not collide with + other key strings or a ValueError is raised. + + If the value of a Link or Multilink property contains an invalid + node id, a ValueError is raised. + """ + if not propvalues: + return + if self.db.journaltag is None: + raise DatabaseError, 'Database open read-only' + nodeid = str(nodeid) + node = self.db.getnode(self.classname, nodeid) + if node.has_key(RETIRED_FLAG): + raise IndexError + num_re = re.compile('^\d+$') + for key, value in propvalues.items(): + if not node.has_key(key): + raise KeyError, key + + if key == self.key: + try: + self.lookup(value) + except KeyError: + pass + else: + raise ValueError, 'node with key "%s" exists'%value + + prop = self.properties[key] + + if prop.isLinkType: + value = str(value) + link_class = self.properties[key].classname + if not num_re.match(value): + try: + value = self.db.classes[link_class].lookup(value) + except: + raise ValueError, 'new property "%s": %s not a %s'%( + key, value, self.properties[key].classname) + + if not self.db.hasnode(link_class, value): + raise ValueError, '%s has no node %s'%(link_class, value) + + # register the unlink with the old linked node + if node[key] is not None: + self.db.addjournal(link_class, node[key], 'unlink', + (self.classname, nodeid, key)) + + # register the link with the newly linked node + if value is not None: + self.db.addjournal(link_class, value, 'link', + (self.classname, nodeid, key)) + + elif prop.isMultilinkType: + if type(value) != type([]): + raise TypeError, 'new property "%s" not a list of ids'%key + link_class = self.properties[key].classname + l = [] + for entry in map(str, value): + if not num_re.match(entry): + try: + entry = self.db.classes[link_class].lookup(entry) + except: + raise ValueError, 'new property "%s": %s not a %s'%( + key, entry, self.properties[key].classname) + l.append(entry) + value = l + propvalues[key] = value + + #handle removals + l = node[key] + for id in l[:]: + if id in value: + continue + # register the unlink with the old linked node + self.db.addjournal(link_class, id, 'unlink', + (self.classname, nodeid, key)) + l.remove(id) + + # handle additions + for id in value: + if not self.db.hasnode(link_class, id): + raise ValueError, '%s has no node %s'%(link_class, id) + if id in l: + continue + # register the link with the newly linked node + self.db.addjournal(link_class, id, 'link', + (self.classname, nodeid, key)) + l.append(id) + + elif prop.isStringType: + if value is not None and type(value) != type(''): + raise TypeError, 'new property "%s" not a string'%key + + elif prop.isDateType: + if not hasattr(value, 'isDate'): + raise TypeError, 'new property "%s" not a Date'% key + + elif prop.isIntervalType: + if not hasattr(value, 'isInterval'): + raise TypeError, 'new property "%s" not an Interval'% key + + node[key] = value + + self.db.setnode(self.classname, nodeid, node) + self.db.addjournal(self.classname, nodeid, 'set', propvalues) + + def retire(self, nodeid): + """Retire a node. + + The properties on the node remain available from the get() method, + and the node's id is never reused. + + Retired nodes are not returned by the find(), list(), or lookup() + methods, and other nodes may reuse the values of their key properties. + """ + nodeid = str(nodeid) + if self.db.journaltag is None: + raise DatabaseError, 'Database open read-only' + node = self.db.getnode(self.classname, nodeid) + node[RETIRED_FLAG] = 1 + self.db.setnode(self.classname, nodeid, node) + self.db.addjournal(self.classname, nodeid, 'retired', None) + + def history(self, nodeid): + """Retrieve the journal of edits on a particular node. + + 'nodeid' must be the id of an existing node of this class or an + IndexError is raised. + + The returned list contains tuples of the form + + (date, tag, action, params) + + 'date' is a Timestamp object specifying the time of the change and + 'tag' is the journaltag specified when the database was opened. + """ + return self.db.getjournal(self.classname, nodeid) + + # Locating nodes: + + def setkey(self, propname): + """Select a String property of this class to be the key property. + + 'propname' must be the name of a String property of this class or + None, or a TypeError is raised. The values of the key property on + all existing nodes must be unique or a ValueError is raised. + """ + self.key = propname + + def getkey(self): + """Return the name of the key property for this class or None.""" + return self.key + + # TODO: set up a separate index db file for this? profile? + def lookup(self, keyvalue): + """Locate a particular node by its key property and return its id. + + If this class has no key property, a TypeError is raised. If the + 'keyvalue' matches one of the values for the key property among + the nodes in this class, the matching node's id is returned; + otherwise a KeyError is raised. + """ + cldb = self.db.getclassdb(self.classname) + for nodeid in self.db.getnodeids(self.classname, cldb): + node = self.db.getnode(self.classname, nodeid, cldb) + if node.has_key(RETIRED_FLAG): + continue + if node[self.key] == keyvalue: + return nodeid + cldb.close() + raise KeyError, keyvalue + + # XXX: change from spec - allows multiple props to match + def find(self, **propspec): + """Get the ids of nodes in this class which link to a given node. + + 'propspec' consists of keyword args propname=nodeid + 'propname' must be the name of a property in this class, or a + KeyError is raised. That property must be a Link or Multilink + property, or a TypeError is raised. + + 'nodeid' must be the id of an existing node in the class linked + to by the given property, or an IndexError is raised. + """ + propspec = propspec.items() + for propname, nodeid in propspec: + nodeid = str(nodeid) + # check the prop is OK + prop = self.properties[propname] + if not prop.isLinkType and not prop.isMultilinkType: + raise TypeError, "'%s' not a Link/Multilink property"%propname + if not self.db.hasnode(prop.classname, nodeid): + raise ValueError, '%s has no node %s'%(link_class, nodeid) + + # ok, now do the find + cldb = self.db.getclassdb(self.classname) + l = [] + for id in self.db.getnodeids(self.classname, cldb): + node = self.db.getnode(self.classname, id, cldb) + if node.has_key(RETIRED_FLAG): + continue + for propname, nodeid in propspec: + nodeid = str(nodeid) + property = node[propname] + if prop.isLinkType and nodeid == property: + l.append(id) + elif prop.isMultilinkType and nodeid in property: + l.append(id) + cldb.close() + return l + + def stringFind(self, **requirements): + """Locate a particular node by matching a set of its String properties. + + If the property is not a String property, a TypeError is raised. + + The return is a list of the id of all nodes that match. + """ + for propname in requirements.keys(): + prop = self.properties[propname] + if not prop.isStringType: + raise TypeError, "'%s' not a String property"%propname + l = [] + cldb = self.db.getclassdb(self.classname) + for nodeid in self.db.getnodeids(self.classname, cldb): + node = self.db.getnode(self.classname, nodeid, cldb) + if node.has_key(RETIRED_FLAG): + continue + for key, value in requirements.items(): + if node[key] != value: + break + else: + l.append(nodeid) + cldb.close() + return l + + def list(self): + """Return a list of the ids of the active nodes in this class.""" + l = [] + cn = self.classname + cldb = self.db.getclassdb(cn) + for nodeid in self.db.getnodeids(cn, cldb): + node = self.db.getnode(cn, nodeid, cldb) + if node.has_key(RETIRED_FLAG): + continue + l.append(nodeid) + l.sort() + cldb.close() + return l + + # XXX not in spec + def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')): + ''' Return a list of the ids of the active nodes in this class that + match the 'filter' spec, sorted by the group spec and then the + sort spec + ''' + cn = self.classname + + # optimise filterspec + l = [] + props = self.getprops() + for k, v in filterspec.items(): + propclass = props[k] + if propclass.isLinkType: + if type(v) is not type([]): + v = [v] + # replace key values with node ids + u = [] + link_class = self.db.classes[propclass.classname] + for entry in v: + if not num_re.match(entry): + try: + entry = link_class.lookup(entry) + except: + raise ValueError, 'new property "%s": %s not a %s'%( + key, entry, self.properties[key].classname) + u.append(entry) + + l.append((0, k, u)) + elif propclass.isMultilinkType: + if type(v) is not type([]): + v = [v] + # replace key values with node ids + u = [] + link_class = self.db.classes[propclass.classname] + for entry in v: + if not num_re.match(entry): + try: + entry = link_class.lookup(entry) + except: + raise ValueError, 'new property "%s": %s not a %s'%( + key, entry, self.properties[key].classname) + u.append(entry) + l.append((1, k, u)) + elif propclass.isStringType: + v = v[0] + if '*' in v or '?' in v: + # simple glob searching + v = v.replace(v, '?', '.') + v = v.replace(v, '*', '.*?') + l.append((2, k, v)) + elif v[0] == '^': + # start-anchored + if v[-1] == '$': + # _and_ end-anchored + l.append((6, k, v[1:-1])) + l.append((3, k, v[1:])) + elif v[-1] == '$': + # end-anchored + l.append((4, k, v[:-1])) + else: + # substring + l.append((5, k, v)) + else: + l.append((6, k, v)) + filterspec = l + + # now, find all the nodes that are active and pass filtering + l = [] + cldb = self.db.getclassdb(cn) + for nodeid in self.db.getnodeids(cn, cldb): + node = self.db.getnode(cn, nodeid, cldb) + if node.has_key(RETIRED_FLAG): + continue + # apply filter + for t, k, v in filterspec: + if t == 0 and node[k] not in v: + # link - if this node'd property doesn't appear in the + # filterspec's nodeid list, skip it + break + elif t == 1: + # multilink - if any of the nodeids required by the + # filterspec aren't in this node's property, then skip + # it + for value in v: + if value not in node[k]: + break + else: + continue + break + elif t == 2 and not v.match(node[k]): + # RE search + break + elif t == 3 and node[k][:len(v)] != v: + # start anchored + break + elif t == 4 and node[k][-len(v):] != v: + # end anchored + break + elif t == 5 and node[k].find(v) == -1: + # substring search + break + elif t == 6 and node[k] != v: + # straight value comparison for the other types + break + else: + l.append((nodeid, node)) + l.sort() + cldb.close() + + # optimise sort + m = [] + for entry in sort: + if entry[0] != '-': + m.append(('+', entry)) + else: + m.append((entry[0], entry[1:])) + sort = m + + # optimise group + m = [] + for entry in group: + if entry[0] != '-': + m.append(('+', entry)) + else: + m.append((entry[0], entry[1:])) + group = m + + # now, sort the result + def sortfun(a, b, sort=sort, group=group, properties=self.getprops(), + db = self.db, cl=self): + a_id, an = a + b_id, bn = b + for list in group, sort: + for dir, prop in list: + # handle the properties that might be "faked" + if not an.has_key(prop): + an[prop] = cl.get(a_id, prop) + av = an[prop] + if not bn.has_key(prop): + bn[prop] = cl.get(b_id, prop) + bv = bn[prop] + + # sorting is class-specific + propclass = properties[prop] + + # String and Date values are sorted in the natural way + if propclass.isStringType: + # make sure that case doesn't get involved + if av[0] in string.uppercase: + av = an[prop] = av.lower() + if bv[0] in string.uppercase: + bv = bn[prop] = bv.lower() + if propclass.isStringType or propclass.isDateType: + if dir == '+': + r = cmp(av, bv) + if r != 0: return r + elif dir == '-': + r = cmp(bv, av) + if r != 0: return r + + # Link properties are sorted according to the value of + # the "order" property on the linked nodes if it is + # present; or otherwise on the key string of the linked + # nodes; or finally on the node ids. + elif propclass.isLinkType: + link = db.classes[propclass.classname] + if link.getprops().has_key('order'): + if dir == '+': + r = cmp(link.get(av, 'order'), + link.get(bv, 'order')) + if r != 0: return r + elif dir == '-': + r = cmp(link.get(bv, 'order'), + link.get(av, 'order')) + if r != 0: return r + elif link.getkey(): + key = link.getkey() + if dir == '+': + r = cmp(link.get(av, key), link.get(bv, key)) + if r != 0: return r + elif dir == '-': + r = cmp(link.get(bv, key), link.get(av, key)) + if r != 0: return r + else: + if dir == '+': + r = cmp(av, bv) + if r != 0: return r + elif dir == '-': + r = cmp(bv, av) + if r != 0: return r + + # Multilink properties are sorted according to how many + # links are present. + elif propclass.isMultilinkType: + if dir == '+': + r = cmp(len(av), len(bv)) + if r != 0: return r + elif dir == '-': + r = cmp(len(bv), len(av)) + if r != 0: return r + return cmp(a[0], b[0]) + l.sort(sortfun) + return [i[0] for i in l] + + def count(self): + """Get the number of nodes in this class. + + If the returned integer is 'numnodes', the ids of all the nodes + in this class run from 1 to numnodes, and numnodes+1 will be the + id of the next node to be created in this class. + """ + return self.db.countnodes(self.classname) + + # Manipulating properties: + + def getprops(self): + """Return a dictionary mapping property names to property objects.""" + return self.properties + + def addprop(self, **properties): + """Add properties to this class. + + The keyword arguments in 'properties' must map names to property + objects, or a TypeError is raised. None of the keys in 'properties' + may collide with the names of existing properties, or a ValueError + is raised before any properties have been added. + """ + for key in properties.keys(): + if self.properties.has_key(key): + raise ValueError, key + self.properties.update(properties) + + +# XXX not in spec +class Node: + ''' A convenience wrapper for the given node + ''' + def __init__(self, cl, nodeid): + self.__dict__['cl'] = cl + self.__dict__['nodeid'] = nodeid + def keys(self): + return self.cl.getprops().keys() + def has_key(self, name): + return self.cl.getprops().has_key(name) + def __getattr__(self, name): + if self.__dict__.has_key(name): + return self.__dict__['name'] + try: + return self.cl.get(self.nodeid, name) + except KeyError, value: + raise AttributeError, str(value) + def __getitem__(self, name): + return self.cl.get(self.nodeid, name) + def __setattr__(self, name, value): + try: + return self.cl.set(self.nodeid, **{name: value}) + except KeyError, value: + raise AttributeError, str(value) + def __setitem__(self, name, value): + self.cl.set(self.nodeid, **{name: value}) + def history(self): + return self.cl.history(self.nodeid) + def retire(self): + return self.cl.retire(self.nodeid) + + +def Choice(name, *options): + cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String()) + for i in range(len(options)): + cl.create(name=option[i], order=i) + return hyperdb.Link(name) + + +if __name__ == '__main__': + import pprint + db = Database("test_db", "richard") + status = Class(db, "status", name=String()) + status.setkey("name") + print db.status.create(name="unread") + print db.status.create(name="in-progress") + print db.status.create(name="testing") + print db.status.create(name="resolved") + print db.status.count() + print db.status.list() + print db.status.lookup("in-progress") + db.status.retire(3) + print db.status.list() + issue = Class(db, "issue", title=String(), status=Link("status")) + db.issue.create(title="spam", status=1) + db.issue.create(title="eggs", status=2) + db.issue.create(title="ham", status=4) + db.issue.create(title="arguments", status=2) + db.issue.create(title="abuse", status=1) + user = Class(db, "user", username=String(), password=String()) + user.setkey("username") + db.issue.addprop(fixer=Link("user")) + print db.issue.getprops() +#{"title": , "status": , +#"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) +# [(, "ping", "create", {"title": "abuse", "status": 1}), +# (, "ping", "set", {"status": 2})] + print db.status.history(1) +# [(, "ping", "link", ("issue", 5, "status")), +# (, "ping", "unlink", ("issue", 5, "status"))] + print db.status.history(2) +# [(, "ping", "link", ("issue", 5, "status"))] + + # TODO: set up some filter tests + diff --git a/roundup-mailgw.py b/roundup-mailgw.py new file mode 100755 index 0000000..5462016 --- /dev/null +++ b/roundup-mailgw.py @@ -0,0 +1,273 @@ +#! /usr/bin/python +''' +Incoming messages are examined for multiple parts. In a multipart/mixed +message or part, each subpart is extracted and examined. In a +multipart/alternative message or part, we look for a text/plain subpart and +ignore the other parts. The text/plain subparts are assembled to form the +textual body of the message, to be stored in the file associated with a +"msg" class node. Any parts of other types are each stored in separate +files and given "file" class nodes that are linked to the "msg" node. + +The "summary" property on message nodes is taken from the first non-quoting +section in the message body. The message body is divided into sections by +blank lines. Sections where the second and all subsequent lines begin with +a ">" or "|" character are considered "quoting sections". The first line of +the first non-quoting section becomes the summary of the message. + +All of the addresses in the To: and Cc: headers of the incoming message are +looked up among the user nodes, and the corresponding users are placed in +the "recipients" property on the new "msg" node. The address in the From: +header similarly determines the "author" property of the new "msg" +node. The default handling for addresses that don't have corresponding +users is to create new users with no passwords and a username equal to the +address. (The web interface does not permit logins for users with no +passwords.) If we prefer to reject mail from outside sources, we can simply +register an auditor on the "user" class that prevents the creation of user +nodes with no passwords. + +The subject line of the incoming message is examined to determine whether +the message is an attempt to create a new item or to discuss an existing +item. A designator enclosed in square brackets is sought as the first thing +on the subject line (after skipping any "Fwd:" or "Re:" prefixes). + +If an item designator (class name and id number) is found there, the newly +created "msg" node is added to the "messages" property for that item, and +any new "file" nodes are added to the "files" property for the item. + +If just an item class name is found there, we attempt to create a new item +of that class with its "messages" property initialized to contain the new +"msg" node and its "files" property initialized to contain any new "file" +nodes. + +Both cases may trigger detectors (in the first case we are calling the +set() method to add the message to the item's spool; in the second case we +are calling the create() method to create a new node). If an auditor raises +an exception, the original message is bounced back to the sender with the +explanatory message given in the exception. +''' + +import sys +if int(sys.version[0]) < 2: + print "Roundup requires Python 2.0 or newer." + sys.exit(0) + +import string, re, os, mimetools, StringIO, smtplib, socket, binascii, quopri +import config, date, roundupdb + +def getPart(fp, boundary): + line = '' + s = StringIO.StringIO() + while 1: + line_n = fp.readline() + if not line_n: + break + line = line_n.strip() + if line == '--'+boundary+'--': + break + if line == '--'+boundary: + break + s.write(line_n) + if not s.getvalue().strip(): + return None + return s + +subject_re = re.compile(r'(\[?(fwd|re):\s*)*' + r'(\[(?P[^\d]+)(?P\d+)?\])' + r'(?P[^\[]+)(\[(?P<args>.+?)\])?', re.I) + +def roundup_mail(db, fp): + # ok, figure the subject, author, recipients and content-type + message = mimetools.Message(fp) + try: + handle_message(db, message) + except: + # send an email to the people who missed out + sendto = [message.getaddrlist('from')[0][1]] + m = ['Subject: failed issue tracker submission'] + m.append('') + # TODO as attachments? + m.append('---- traceback of failure ----') + return + s = StringIO.StringIO() + import traceback + traceback.print_exc(None, s) + m.append(s.getvalue()) + m.append('---- failed message follows ----') + try: + fp.seek(0) + except: + pass + m.append(fp.read()) + try: + smtp = smtplib.SMTP(config.MAILHOST) + smtp.sendmail(config.ADMIN_EMAIL, sendto, '\n'.join(m)) + except socket.error, value: + return "Couldn't send confirmation email: mailhost %s"%value + except smtplib.SMTPException, value: + return "Couldn't send confirmation email: %s"%value + +def handle_message(db, message): + # handle the subject line + m = subject_re.match(message.getheader('subject')) + if not m: + raise ValueError, 'No [designator] found in subject "%s"' + classname = m.group('classname') + nodeid = m.group('nodeid') + title = m.group('title').strip() + subject_args = m.group('args') + cl = db.getclass(classname) + properties = cl.getprops() + props = {} + args = m.group('args') + if args: + for prop in string.split(m.group('args'), ';'): + try: + key, value = prop.split('=') + except ValueError, message: + raise ValueError, 'Args list not of form [arg=value,value,...;arg=value,value,value..] (specific exception message was "%s")'%message + type = properties[key] + if type.isStringType: + props[key] = value + elif type.isDateType: + props[key] = date.Date(value) + elif type.isIntervalType: + props[key] = date.Interval(value) + elif type.isLinkType: + props[key] = value + elif type.isMultilinkType: + props[key] = value.split(',') + + # handle the users + author = db.uidFromAddress(message.getaddrlist('from')[0]) + recipients = [] + for recipient in message.getaddrlist('to') + message.getaddrlist('cc'): + if recipient[1].strip().lower() == config.ISSUE_TRACKER_EMAIL: + continue + recipients.append(db.uidFromAddress(recipient)) + + # now handle the body - find the message + content_type = message.gettype() + attachments = [] + if content_type == 'multipart/mixed': + boundary = message.getparam('boundary') + # skip over the intro to the first boundary + part = getPart(message.fp, boundary) + content = None + while 1: + # get the next part + part = getPart(message.fp, boundary) + if part is None: + break + # parse it + part.seek(0) + submessage = mimetools.Message(part) + subtype = submessage.gettype() + if subtype == 'text/plain' and not content: + # this one's our content + content = part.read() + elif subtype == 'message/rfc822': + i = part.tell() + subsubmess = mimetools.Message(part) + name = subsubmess.getheader('subject') + part.seek(i) + attachments.append((name, 'message/rfc822', part.read())) + else: + # try name on Content-Type + name = submessage.getparam('name') + # this is just an attachment + data = part.read() + encoding = submessage.getencoding() + if encoding == 'base64': + data = binascii.a2b_base64(data) + elif encoding == 'quoted-printable': + data = quopri.decode(data) + elif encoding == 'uuencoded': + data = binascii.a2b_uu(data) + attachments.append((name, submessage.gettype(), data)) + if content is None: + raise ValueError, 'No text/plain part found' + + elif content_type[:10] == 'multipart/': + boundary = message.getparam('boundary') + # skip over the intro to the first boundary + getPart(message.fp, boundary) + content = None + while 1: + # get the next part + part = getPart(message.fp, boundary) + if part is None: + break + # parse it + part.seek(0) + submessage = mimetools.Message(part) + if submessage.gettype() == 'text/plain' and not content: + # this one's our content + content = part.read() + if content is None: + raise ValueError, 'No text/plain part found' + + elif content_type != 'text/plain': + raise ValueError, 'No text/plain part found' + + else: + content = message.fp.read() + + # extract out the summary from the message + summary = [] + for line in content.split('\n'): + line = line.strip() + if summary and not line: + break + if not line: + summary.append('') + elif line[0] not in '>|': + summary.append(line) + summary = '\n'.join(summary) + + # handle the files + files = [] + for (name, type, data) in attachments: + files.append(db.file.create(type=type, name=name, content=data)) + + # now handle the db stuff + if nodeid: + # If an item designator (class name and id number) is found there, the + # newly created "msg" node is added to the "messages" property for + # that item, and any new "file" nodes are added to the "files" + # property for the item. + message_id = db.msg.create(author=author, recipients=recipients, + date=date.Date('.'), summary=summary, content=content, + files=files) + messages = cl.get(nodeid, 'messages') + messages.append(message_id) + props['messages'] = messages + apply(cl.set, (nodeid, ), props) + else: + # If just an item class name is found there, we attempt to create a + # new item of that class with its "messages" property initialized to + # contain the new "msg" node and its "files" property initialized to + # contain any new "file" nodes. + message_id = db.msg.create(author=author, recipients=recipients, + date=date.Date('.'), summary=summary, content=content, + files=files) + if not props.has_key('assignedto'): + props['assignedto'] = 1 # "admin" + if not props.has_key('priority'): + props['priority'] = 1 # "bug-fatal" + if not props.has_key('status'): + props['status'] = 1 # "unread" + if not props.has_key('title'): + props['title'] = title + props['messages'] = [message_id] + props['nosy'] = recipients[:] + props['nosy'].append(author) + props['nosy'].sort() + nodeid = apply(cl.create, (), props) + + return 0 + +if __name__ == '__main__': + db = roundupdb.openDB(config.DATABASE, 'admin', '1') + roundup_mail(db, sys.stdin) + db.close() + diff --git a/roundup.cgi b/roundup.cgi new file mode 100755 index 0000000..6ea7189 --- /dev/null +++ b/roundup.cgi @@ -0,0 +1,71 @@ +#!/usr/bin/env python + +import sys +if int(sys.version[0]) < 2: + print "Content-Type: text/plain\n" + print "Roundup requires Python 2.0 or newer." + +import os, traceback, StringIO, cgi, binascii + +try: + import cgitb +except: + print "Content-Type: text/html\n" + print "Failed to import cgitb" + print "<pre>" + s = StringIO.StringIO() + traceback.print_exc(None, s) + print cgi.escape(s.getvalue()) + print "</pre>" + +# Force import first from the same directory where this script lives. +dir, name = os.path.split(sys.argv[0]) +sys.path[:0] = [dir or "."] + +def main(out): + import config, roundupdb, roundup_cgi + db = roundupdb.openDB(config.DATABASE, 'admin') + auth = os.environ.get("HTTP_CGI_AUTHORIZATION", None) + message = 'Unauthorised' + if auth: + l = binascii.a2b_base64(auth.split(' ')[1]).split(':') + user = l[0] + password = None + if len(l) > 1: + password = l[1] + try: + uid = db.user.lookup(user) + except KeyError: + auth = None + message = 'Username not recognised' + else: + if password != db.user.get(uid, 'password'): + message = 'Incorrect password' + auth = None + if not auth: + out.write('Content-Type: text/html\n') + out.write('Status: 401\n') + out.write('WWW-Authenticate: basic realm="Roundup"\n\n') + keys = os.environ.keys() + keys.sort() + out.write(message) + return + client = roundup_cgi.Client(out, os.environ, user) + try: + client.main() + except roundup_cgi.Unauthorised: + out.write('Content-Type: text/html\n') + out.write('Status: 403\n\n') + out.write('Unauthorised') + +out, err = sys.stdout, sys.stderr +try: + import config, roundup_cgi + sys.stdout = sys.stderr = open(config.LOG, 'a') + main(out) +except: + sys.stdout, sys.stderr = out, err + out.write('Content-Type: text/html\n\n') + cgitb.handler() +sys.stdout.flush() +sys.stdout, sys.stderr = out, err diff --git a/roundup.py b/roundup.py new file mode 100755 index 0000000..32b13f7 --- /dev/null +++ b/roundup.py @@ -0,0 +1,204 @@ +#! /usr/bin/python + +import sys +if int(sys.version[0]) < 2: + print 'Roundup requires python 2.0 or later.' + sys.exit(1) + +import string, os, getpass +import config, date, roundupdb + +def determineLogin(argv): + n = 2 + name = password = '' + if sys.argv[2] == '-user': + l = sys.argv[3].split(':') + name = l[0] + if len(l) > 1: + password = l[1] + n = 4 + elif os.environ.has_key('ROUNDUP_LOGIN'): + l = os.environ['ROUNDUP_LOGIN'].split(':') + name = l[0] + if len(l) > 1: + password = l[1] + while not name: + name = raw_input('Login name: ') + while not password: + password = getpass.getpass(' password: ') + return n, roundupdb.openDB(config.DATABASE, name, password) + +def usage(): + print '''Usage: + + roundup init + roundup spec classname + roundup create [-user login] classanme propname=value ... + roundup list [-list] classname + roundup history [-list] designator + roundup get [-list] designator[,designator,...] propname + roundup set [-user login] designator[,designator,...] propname=value ... + roundup find [-list] classname propname=value ... + roundup retire designator[,designator,...] + +A designator is a classname and a nodeid concatenated, eg. bug1, user10, ... + +Property values are represented as strings in command arguments and in the +printed results: + . Strings are, well, strings. + . Date values are printed in the full date format in the local time zone, and + accepted in the full format or any of the partial formats explained below. + . Link values are printed as node designators. When given as an argument, + node designators and key strings are both accepted. + . Multilink values are printed as lists of node designators joined by commas. + When given as an argument, node designators and key strings are both + accepted; an empty string, a single node, or a list of nodes joined by + commas is accepted. + +When multiple nodes are specified to the roundup get or roundup set +commands, the specified properties are retrieved or set on all the listed +nodes. + +When multiple results are returned by the roundup get or roundup find +commands, they are printed one per line (default) or joined by commas (with +the -list) option. + +Where the command changes data, a login name/password is required. The +login may be specified as either "name" or "name:password". + . ROUNDUP_LOGIN environment variable + . the -user command-line option +If either the name or password is not supplied, they are obtained from the +command-line. + +Date format examples: + "2000-04-17.03:45" means <Date 2000-04-17.08:45:00> + "2000-04-17" means <Date 2000-04-17.00:00:00> + "01-25" means <Date yyyy-01-25.00:00:00> + "08-13.22:13" means <Date yyyy-08-14.03:13:00> + "11-07.09:32:43" means <Date yyyy-11-07.14:32:43> + "14:25" means <Date yyyy-mm-dd.19:25:00> + "8:47:11" means <Date yyyy-mm-dd.13:47:11> + "." means "right now" +''' + +def main(): + + if len(sys.argv) == 1: + usage() + return 1 + + command = sys.argv[1] + if command == 'init': + password = '' + confirm = 'x' + while password != confirm: + password = getpass.getpass('Admin Password:') + confirm = getpass.getpass(' Confirm:') + roundupdb.initDB(config.DATABASE, password) + return 0 + + if command == 'get': + db = roundupdb.openDB(config.DATABASE) + designators = string.split(sys.argv[2], ',') + propname = sys.argv[3] + for designator in designators: + classname, nodeid = roundupdb.splitDesignator(designator) + print db.getclass(classname).get(nodeid, propname) + + elif command == 'set': + n, db = determineLogin(sys.argv) + designators = string.split(sys.argv[n], ',') + props = {} + for prop in sys.argv[n+1:]: + key, value = prop.split('=') + props[key] = value + for designator in designators: + classname, nodeid = roundupdb.splitDesignator(designator) + cl = db.getclass(classname) + properties = cl.getprops() + for key, value in props.items(): + type = properties[key] + if type.isStringType: + continue + elif type.isDateType: + props[key] = date.Date(value) + elif type.isIntervalType: + props[key] = date.Interval(value) + elif type.isLinkType: + props[key] = value + elif type.isMultilinkType: + props[key] = value.split(',') + apply(cl.set, (nodeid, ), props) + + elif command == 'find': + db = roundupdb.openDB(config.DATABASE) + classname = sys.argv[2] + cl = db.getclass(classname) + + # look up the linked-to class and get the nodeid that has the value + propname, value = sys.argv[3:].split('=') + propcl = cl[propname].classname + nodeid = propcl.lookup(value) + + # now do the find + print cl.find(propname, nodeid) + + elif command == 'spec': + db = roundupdb.openDB(config.DATABASE) + classname = sys.argv[2] + cl = db.getclass(classname) + for key, value in cl.properties.items(): + print '%s: %s'%(key, value) + + elif command == 'create': + n, db = determineLogin(sys.argv) + classname = sys.argv[n] + cl = db.getclass(classname) + props = {} + properties = cl.getprops() + for prop in sys.argv[n+1:]: + key, value = prop.split('=') + type = properties[key] + if type.isStringType: + props[key] = value + elif type.isDateType: + props[key] = date.Date(value) + elif type.isIntervalType: + props[key] = date.Interval(value) + elif type.isLinkType: + props[key] = value + elif type.isMultilinkType: + props[key] = value.split(',') + print apply(cl.create, (), props) + + elif command == 'list': + db = roundupdb.openDB(config.DATABASE) + classname = sys.argv[2] + cl = db.getclass(classname) + key = cl.getkey() or cl.properties.keys()[0] + for nodeid in cl.list(): + value = cl.get(nodeid, key) + print "%4s: %s"%(nodeid, value) + + elif command == 'history': + db = roundupdb.openDB(config.DATABASE) + classname, nodeid = roundupdb.splitDesignator(sys.argv[2]) + print db.getclass(classname).history(nodeid) + + elif command == 'retire': + n, db = determineLogin(sys.argv) + designators = string.split(sys.argv[2], ',') + for designator in designators: + classname, nodeid = roundupdb.splitDesignator(designator) + db.getclass(classname).retire(nodeid) + + else: + usage() + return 1 + + db.close() + return 0 + +if __name__ == '__main__': + sys.exit(main()) + diff --git a/roundup_cgi.py b/roundup_cgi.py new file mode 100644 index 0000000..4319833 --- /dev/null +++ b/roundup_cgi.py @@ -0,0 +1,479 @@ +import os, cgi, pprint, StringIO, urlparse, re, traceback + +import config, roundupdb, template, date + +class Unauthorised(ValueError): + pass + +class Client: + def __init__(self, out, env, user): + self.out = out + self.headers_done = 0 + self.env = env + self.path = env.get("PATH_INFO", '').strip() + self.user = user + self.form = cgi.FieldStorage(environ=env) + self.split_path = self.path.split('/')[1:] + self.db = roundupdb.openDB(config.DATABASE, self.user) + self.headers_done = 0 + self.debug = 0 + + def header(self, headers={'Content-Type':'text/html'}): + if not headers.has_key('Content-Type'): + headers['Content-Type'] = 'text/html' + for entry in headers.items(): + self.out.write('%s: %s\n'%entry) + self.out.write('\n') + self.headers_done = 1 + + def pagehead(self, title, message=None): + url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/') + machine = self.env['SERVER_NAME'] + port = self.env['SERVER_PORT'] + if port != '80': machine = machine + ':' + port + base = urlparse.urlunparse(('http', machine, url, None, None, None)) + if message is not None: + message = '<div class="system-msg">%s</div>'%message + else: + message = '' + style = open('style.css').read() + userid = self.db.user.lookup(self.user) + if self.user == 'admin': + extras = ' | <a href="list_classes">Class List</a>' + else: + extras = '' + self.write('''<html><head> +<title>%s + + + +%s + + + + + + +
%s%s
All issues | +Bugs | +Support | +Wishlist | +New Issue +%sYour Details
+'''%(title, style, message, title, self.user, extras, userid)) + + def pagefoot(self): + if self.debug: + self.write('
') + self.write('
Path
') + self.write('
%s
'%(', '.join(map(repr, self.split_path)))) + keys = self.form.keys() + keys.sort() + if keys: + self.write('
Form entries
') + for k in self.form.keys(): + v = str(self.form[k].value) + self.write('
%s:%s
'%(k, cgi.escape(v))) + keys = self.env.keys() + keys.sort() + self.write('
CGI environment
') + for k in keys: + v = self.env[k] + self.write('
%s:%s
'%(k, cgi.escape(v))) + self.write('
') + self.write('') + + 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 = '
%s
'%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 = '
%s
'%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('\n') + for cn in classnames: + cl = self.db.getclass(cn) + self.write(''%cn.capitalize()) + for key, value in cl.properties.items(): + if value is None: value = '' + else: value = str(value) + self.write(''%( + key, cgi.escape(value))) + self.write('
%s
%s%s
') + self.pagefoot() + else: + raise Unauthorised + + def main(self, dre=re.compile(r'([^\d]+)(\d+)'), + nre=re.compile(r'new(\w+)')): + path = self.split_path + if not path or path[0] in ('', 'index'): + self.index() + elif len(path) == 1: + if path[0] == 'list_classes': + self.classes() + return + m = dre.match(path[0]) + if m: + self.classname = m.group(1) + self.nodeid = m.group(2) + getattr(self, 'show%s'%self.classname)() + return + m = nre.match(path[0]) + if m: + self.classname = m.group(1) + getattr(self, 'new%s'%self.classname)() + return + self.classname = path[0] + self.list() + else: + raise 'ValueError', 'Path not understood' + + def __del__(self): + self.db.close() + diff --git a/roundupdb.py b/roundupdb.py new file mode 100644 index 0000000..96e62f8 --- /dev/null +++ b/roundupdb.py @@ -0,0 +1,371 @@ +import re, os, smtplib, socket + +import config, hyperdb, date + +def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')): + ''' Take a foo123 and return ('foo', 123) + ''' + m = dre.match(designator) + return m.group(1), m.group(2) + +class Database(hyperdb.Database): + def getuid(self): + """Return the id of the "user" node associated with the user + that owns this connection to the hyperdatabase.""" + return self.user.lookup(self.journaltag) + + def uidFromAddress(self, address): + ''' address is from the rfc822 module, and therefore is (name, addr) + ''' + (realname, address) = address + users = self.user.stringFind(address=address) + if users: return users[0] + return self.user.create(username=address, address=address, + realname=realname) + +class Class(hyperdb.Class): + # Overridden methods: + def __init__(self, db, classname, **properties): + hyperdb.Class.__init__(self, db, classname, **properties) + self.auditors = {'create': [], 'set': [], 'retire': []} + self.reactors = {'create': [], 'set': [], 'retire': []} + + def create(self, **propvalues): + """These operations trigger detectors and can be vetoed. Attempts + to modify the "creation" or "activity" properties cause a KeyError. + """ + if propvalues.has_key('creation') or propvalues.has_key('activity'): + raise KeyError, '"creation" and "activity" are reserved' + for audit in self.auditors['create']: + audit(self.db, self, None, propvalues) + nodeid = hyperdb.Class.create(self, **propvalues) + for react in self.reactors['create']: + react(self.db, self, nodeid, None) + return nodeid + + def set(self, nodeid, **propvalues): + """These operations trigger detectors and can be vetoed. Attempts + to modify the "creation" or "activity" properties cause a KeyError. + """ + if propvalues.has_key('creation') or propvalues.has_key('activity'): + raise KeyError, '"creation" and "activity" are reserved' + for audit in self.auditors['set']: + audit(self.db, self, nodeid, propvalues) + oldvalues = self.db.getnode(self.classname, nodeid) + hyperdb.Class.set(self, nodeid, **propvalues) + for react in self.reactors['set']: + react(self.db, self, nodeid, oldvalues) + + def retire(self, nodeid): + """These operations trigger detectors and can be vetoed. Attempts + to modify the "creation" or "activity" properties cause a KeyError. + """ + for audit in self.auditors['retire']: + audit(self.db, self, nodeid, None) + hyperdb.Class.retire(self, nodeid) + for react in self.reactors['retire']: + react(self.db, self, nodeid, None) + + # New methods: + + def audit(self, event, detector): + """Register a detector + """ + self.auditors[event].append(detector) + + def react(self, event, detector): + """Register a detector + """ + self.reactors[event].append(detector) + +class FileClass(Class): + def create(self, **propvalues): + ''' snaffle the file propvalue and store in a file + ''' + content = propvalues['content'] + del propvalues['content'] + newid = Class.create(self, **propvalues) + self.setcontent(self.classname, newid, content) + return newid + + def filename(self, classname, nodeid): + # TODO: split into multiple files directories + return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid)) + + def setcontent(self, classname, nodeid, content): + ''' set the content file for this file + ''' + open(self.filename(classname, nodeid), 'wb').write(content) + + def getcontent(self, classname, nodeid): + ''' get the content file for this file + ''' + return open(self.filename(classname, nodeid), 'rb').read() + + def get(self, nodeid, propname): + ''' trap the content propname and get it from the file + ''' + if propname == 'content': + return self.getcontent(self.classname, nodeid) + return Class.get(self, nodeid, propname) + + def getprops(self): + ''' In addition to the actual properties on the node, these methods + provide the "content" property. + ''' + d = Class.getprops(self).copy() + d['content'] = hyperdb.String() + return d + +# XXX deviation from spec +class IssueClass(Class): + # Overridden methods: + + def __init__(self, db, classname, **properties): + """The newly-created class automatically includes the "messages", + "files", "nosy", and "superseder" properties. If the 'properties' + dictionary attempts to specify any of these properties or a + "creation" or "activity" property, a ValueError is raised.""" + if not properties.has_key('title'): + properties['title'] = hyperdb.String() + if not properties.has_key('messages'): + properties['messages'] = hyperdb.Multilink("msg") + if not properties.has_key('files'): + properties['files'] = hyperdb.Multilink("file") + if not properties.has_key('nosy'): + properties['nosy'] = hyperdb.Multilink("user") + if not properties.has_key('superseder'): + properties['superseder'] = hyperdb.Multilink("issue") + if (properties.has_key('creation') or properties.has_key('activity') + or properties.has_key('creator')): + raise ValueError, '"creation", "activity" and "creator" are reserved' + Class.__init__(self, db, classname, **properties) + + def get(self, nodeid, propname): + if propname == 'creation': + return self.db.getjournal(self.classname, nodeid)[0][1] + if propname == 'activity': + return self.db.getjournal(self.classname, nodeid)[-1][1] + if propname == 'creator': + name = self.db.getjournal(self.classname, nodeid)[0][2] + return self.db.user.lookup(name) + return Class.get(self, nodeid, propname) + + def getprops(self): + """In addition to the actual properties on the node, these + methods provide the "creation" and "activity" properties.""" + d = Class.getprops(self).copy() + d['creation'] = hyperdb.Date() + d['activity'] = hyperdb.Date() + d['creator'] = hyperdb.Link("user") + return d + + # New methods: + + def addmessage(self, nodeid, summary, text): + """Add a message to an issue's mail spool. + + A new "msg" node is constructed using the current date, the user that + owns the database connection as the author, and the specified summary + text. + + The "files" and "recipients" fields are left empty. + + The given text is saved as the body of the message and the node is + appended to the "messages" field of the specified issue. + """ + + def sendmessage(self, nodeid, msgid): + """Send a message to the members of an issue's nosy list. + + The message is sent only to users on the nosy list who are not + already on the "recipients" list for the message. + + These users are then added to the message's "recipients" list. + """ + # figure the recipient ids + recipients = self.db.msg.get(msgid, 'recipients') + r = {} + for recipid in recipients: + r[recipid] = 1 + authid = self.db.msg.get(msgid, 'author') + r[authid] = 1 + + # now figure the nosy people who weren't recipients + sendto = [] + nosy = self.get(nodeid, 'nosy') + for nosyid in nosy: + if not r.has_key(nosyid): + sendto.append(nosyid) + recipients.append(nosyid) + + if sendto: + # update the message's recipients list + self.db.msg.set(msgid, recipients=recipients) + + # send an email to the people who missed out + sendto = [self.db.user.get(i, 'address') for i in recipients] + cn = self.classname + title = self.get(nodeid, 'title') or '%s message copy'%cn + m = ['Subject: [%s%s] %s'%(cn, nodeid, title)] + m.append('To: %s'%', '.join(sendto)) + m.append('Reply-To: %s'%config.ISSUE_TRACKER_EMAIL) + m.append('') + m.append(self.db.msg.get(msgid, 'content')) + # TODO attachments + try: + smtp = smtplib.SMTP(config.MAILHOST) + smtp.sendmail(config.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m)) + except socket.error, value: + return "Couldn't send confirmation email: mailhost %s"%value + except smtplib.SMTPException, value: + return "Couldn't send confirmation email: %s"%value + +def nosyreaction(db, cl, nodeid, oldvalues): + ''' A standard detector is provided that watches for additions to the + "messages" property. + + When a new message is added, the detector sends it to all the users on + the "nosy" list for the issue that are not already on the "recipients" + list of the message. + + Those users are then appended to the "recipients" property on the + message, so multiple copies of a message are never sent to the same + user. + + The journal recorded by the hyperdatabase on the "recipients" property + then provides a log of when the message was sent to whom. + ''' + messages = [] + if oldvalues is None: + # the action was a create, so use all the messages in the create + messages = cl.get(nodeid, 'messages') + elif oldvalues.has_key('messages'): + # the action was a set (so adding new messages to an existing issue) + m = {} + for msgid in oldvalues['messages']: + m[msgid] = 1 + messages = [] + # figure which of the messages now on the issue weren't there before + for msgid in cl.get(nodeid, 'messages'): + if not m.has_key(msgid): + messages.append(msgid) + if not messages: + return + + # send a copy to the nosy list + for msgid in messages: + cl.sendmessage(nodeid, msgid) + + # update the nosy list with the recipients from the new messages + nosy = cl.get(nodeid, 'nosy') + n = {} + for nosyid in nosy: n[nosyid] = 1 + change = 0 + # but don't add admin to the nosy list + for msgid in messages: + for recipid in db.msg.get(msgid, 'recipients'): + if recipid != '1' and not n.has_key(recipid): + change = 1 + nosy.append(recipid) + authid = db.msg.get(msgid, 'author') + if authid != '1' and not n.has_key(authid): + change = 1 + nosy.append(authid) + if change: + cl.set(nodeid, nosy=nosy) + +def openDB(storagelocator, name=None, password=None): + ''' Open the Roundup DB + + ... configs up all the classes etc + ''' + db = Database(storagelocator, name) + pri = Class(db, "priority", name=hyperdb.String(), order=hyperdb.String()) + pri.setkey("name") + stat = Class(db, "status", name=hyperdb.String(), order=hyperdb.String()) + stat.setkey("name") + Class(db, "keyword", name=hyperdb.String()) + user = Class(db, "user", username=hyperdb.String(), + password=hyperdb.String(), address=hyperdb.String(), + realname=hyperdb.String(), phone=hyperdb.String(), + organisation=hyperdb.String()) + user.setkey("username") + msg = FileClass(db, "msg", author=hyperdb.Link("user"), + recipients=hyperdb.Multilink("user"), date=hyperdb.Date(), + summary=hyperdb.String(), files=hyperdb.Multilink("file")) + file = FileClass(db, "file", name=hyperdb.String(), type=hyperdb.String()) + + # bugs and support calls etc + rate = Class(db, "rate", name=hyperdb.String(), order=hyperdb.String()) + rate.setkey("name") + source = Class(db, "source", name=hyperdb.String(), order=hyperdb.String()) + source.setkey("name") + platform = Class(db, "platform", name=hyperdb.String(), order=hyperdb.String()) + platform.setkey("name") + product = Class(db, "product", name=hyperdb.String(), order=hyperdb.String()) + product.setkey("name") + Class(db, "timelog", date=hyperdb.Date(), time=hyperdb.String(), + performedby=hyperdb.Link("user"), description=hyperdb.String()) + issue = IssueClass(db, "issue", assignedto=hyperdb.Link("user"), + priority=hyperdb.Link("priority"), status=hyperdb.Link("status"), + rate=hyperdb.Link("rate"), source=hyperdb.Link("source"), + product=hyperdb.Link("product"), platform=hyperdb.Multilink("platform"), + version=hyperdb.String(), + timelog=hyperdb.Multilink("timelog"), customername=hyperdb.String()) + issue.setkey('title') + issue.react('create', nosyreaction) + issue.react('set', nosyreaction) + return db + +def initDB(storagelocator, password): + ''' Initialise the Roundup DB for use + ''' + dbdir = os.path.join(storagelocator, 'files') + if not os.path.isdir(dbdir): + os.makedirs(dbdir) + db = openDB(storagelocator, "admin") + db.clear() + pri = db.getclass('priority') + pri.create(name="fatal-bug", order="1") + pri.create(name="bug", order="2") + pri.create(name="usability", order="3") + pri.create(name="feature", order="4") + + stat = db.getclass('status') + stat.create(name="unread", order="1") + stat.create(name="deferred", order="2") + stat.create(name="chatting", order="3") + stat.create(name="need-eg", order="4") + stat.create(name="in-progress", order="5") + stat.create(name="testing", order="6") + stat.create(name="done-cbb", order="7") + stat.create(name="resolved", order="8") + + rate = db.getclass("rate") + rate.create(name='basic', order="1") + rate.create(name='premium', order="2") + rate.create(name='internal', order="3") + + source = db.getclass("source") + source.create(name='phone', order="1") + source.create(name='e-mail', order="2") + source.create(name='internal', order="3") + source.create(name='internal-qa', order="4") + + platform = db.getclass("platform") + platform.create(name='linux', order="1") + platform.create(name='windows', order="2") + platform.create(name='mac', order="3") + + product = db.getclass("product") + product.create(name='Bizar Shop', order="1") + product.create(name='Bizar Shop Developer', order="2") + product.create(name='Bizar Shop Manual', order="3") + product.create(name='Bizar Shop Developer Manual', order="4") + + user = db.getclass('user') + user.create(username="admin", password=password, address=config.ADMIN_EMAIL) + + db.close() + diff --git a/server.py b/server.py new file mode 100755 index 0000000..b9f2dc2 --- /dev/null +++ b/server.py @@ -0,0 +1,150 @@ +#!/usr/bin/python +""" HTTP Server that serves roundup. + +Stolen from CGIHTTPServer + +""" +import sys +if int(sys.version[0]) < 2: + print "Content-Type: text/plain\n" + print "Roundup requires Python 2.0 or newer." + +__version__ = "0.1" + +__all__ = ["CGIHTTPRequestHandler"] + +import os, urllib, StringIO, traceback, cgi, binascii +import BaseHTTPServer +import SimpleHTTPServer +import date, hyperdb, template, roundupdb, roundup_cgi +import cgitb + +class RoundupRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): + def send_head(self): + """Version of send_head that support CGI scripts""" + return self.run_cgi() + + def run_cgi(self): + """Execute a CGI script.""" + rest = self.path + i = rest.rfind('?') + if i >= 0: + rest, query = rest[:i], rest[i+1:] + else: + query = '' + + # Set up the CGI environment + env = {} + env['REQUEST_METHOD'] = self.command + env['PATH_INFO'] = urllib.unquote(rest) + if query: + env['QUERY_STRING'] = query + host = self.address_string() + if self.headers.typeheader is None: + env['CONTENT_TYPE'] = self.headers.type + else: + env['CONTENT_TYPE'] = self.headers.typeheader + length = self.headers.getheader('content-length') + if length: + env['CONTENT_LENGTH'] = length + co = filter(None, self.headers.getheaders('cookie')) + if co: + env['HTTP_COOKIE'] = ', '.join(co) + env['SCRIPT_NAME'] = '' + env['SERVER_NAME'] = self.server.server_name + env['SERVER_PORT'] = str(self.server.server_port) + + decoded_query = query.replace('+', ' ') + + # if root, setuid to nobody + if not os.getuid(): + nobody = nobody_uid() + os.setuid(nobody) + + # TODO check for file timestamp changes + reload(date) + reload(hyperdb) + reload(roundupdb) + reload(template) + reload(roundup_cgi) + + # initialise the roundupdb, check for auth + db = roundupdb.openDB('db', 'admin') + message = 'Unauthorised' + auth = self.headers.getheader('authorization') + if auth: + l = binascii.a2b_base64(auth.split(' ')[1]).split(':') + user = l[0] + password = None + if len(l) > 1: + password = l[1] + try: + uid = db.user.lookup(user) + except KeyError: + auth = None + message = 'Username not recognised' + else: + if password != db.user.get(uid, 'password'): + message = 'Incorrect password' + auth = None + db.close() + del db + if not auth: + self.send_response(401) + self.send_header('Content-Type', 'text/html') + self.send_header('WWW-Authenticate', 'basic realm="Roundup"') + self.end_headers() + self.wfile.write(message) + return + + self.send_response(200, "Script output follows") + + # do the roundup thang + save_stdin = sys.stdin + try: + sys.stdin = self.rfile + client = roundup_cgi.Client(self.wfile, env, user) + client.main() + except roundup_cgi.Unauthorised: + self.wfile.write('Content-Type: text/html\n') + self.wfile.write('Status: 403\n') + self.wfile.write('Unauthorised') + except: + try: + reload(cgitb) + self.wfile.write(cgitb.breaker()) + self.wfile.write(cgitb.html()) + except: + self.wfile.write("Content-Type: text/html\n\n") + self.wfile.write("
")
+                s = StringIO.StringIO()
+                traceback.print_exc(None, s)
+                self.wfile.write(cgi.escape(s.getvalue()))
+                self.wfile.write("
\n") + sys.stdin = save_stdin + do_POST = run_cgi + + +nobody = None + +def nobody_uid(): + """Internal routine to get nobody's uid""" + global nobody + if nobody: + return nobody + try: + import pwd + except ImportError: + return -1 + try: + nobody = pwd.getpwnam('nobody')[2] + except KeyError: + nobody = 1 + max(map(lambda x: x[2], pwd.getpwall())) + return nobody + +if __name__ == '__main__': + address = ('dirk.adroit', 9080) + httpd = BaseHTTPServer.HTTPServer(address, RoundupRequestHandler) + print 'Roundup server started on', address + httpd.serve_forever() + diff --git a/style.css b/style.css new file mode 100644 index 0000000..2316c7c --- /dev/null +++ b/style.css @@ -0,0 +1,163 @@ +h1 { + font-family: Verdana, Helvetica, sans-serif; + font-size: 18pt; + font-weight: bold; +} + +h2 { + font-family: Verdana, Helvetica, sans-serif; + font-size: 16pt; + font-weight: bold; +} + +h3 { + font-family: Verdana, Helvetica, sans-serif; + font-size: 12pt; + font-weight: bold; +} + +a:hover { + font-family: Verdana, Helvetica, sans-serif; + text-decoration: underline; + color: #333333; +} + +a:link { + font-family: Verdana, Helvetica, sans-serif; + text-decoration: none; + color: #000099; +} + +a { + font-family: Verdana, Helvetica, sans-serif; + text-decoration: none; + color: #000099; +} + +p { + font-family: Verdana, Helvetica, sans-serif; + font-size: 10pt; + color: #333333; +} + +th { + font-family: Verdana, Helvetica, sans-serif; + font-weight: bold; + font-size: 10pt; + color: #333333; +} + +.form-help { + font-family: Verdana, Helvetica, sans-serif; + font-size: 10pt; + color: #333333; +} + +.std-text { + font-family: Verdana, Helvetica, sans-serif; + font-size: 10pt; + color: #333333; +} + +.tab-small { + font-family: Verdana, Helvetica, sans-serif; + font-size: 8pt; + color: #333333; +} + +.location-bar { + background-color: #efefef; + border: none; +} + +.strong-header { + font-family: Verdana, Helvetica, sans-serif; + font-size: 12pt; + font-weight: bold; + background-color: #000000; + color: #ffffff; +} + +.list-header { + background-color: #c0c0c0; + border: none; +} + +.list-item { + font-family: Verdana, Helvetica, sans-serif; + font-size: 10pt; +} + +.list-nav { + font-family: Verdana, Helvetica, sans-serif; + font-size: 10pt; + font-weight: bold; +} + +.row-normal { + background-color: #ffffff; + border: none; + +} + +.row-hilite { + background-color: #efefef; + border: none; +} + +.section-bar { + background-color: #c0c0c0; + border: none; +} + +.system-msg { + font-family: Verdana, Helvetica, sans-serif; + font-size: 10pt; + background-color: #ffffff; + border: 1px solid #000000; + margin-bottom: 6px; + margin-top: 6px; + padding: 4px; + width: 100%; + color: #660033; +} + +.form-title { + font-family: Verdana, Helvetica, sans-serif; + font-weight: bold; + font-size: 12pt; + color: #333333; +} + +.form-label { + font-family: Verdana, Helvetica, sans-serif; + font-weight: bold; + font-size: 10pt; + color: #333333; +} + +.form-optional { + font-family: Verdana, Helvetica, sans-serif; + font-weight: bold; + font-style: italic; + font-size: 10pt; + color: #333333; +} + +.form-element { + font-family: Verdana, Helvetica, aans-serif; + font-size: 10pt; + color: #000000; +} + +.form-text { + font-family: Verdana, Helvetica, sans-serif; + font-size: 10pt; + color: #333333; +} + +.form-mono { + font-family: monospace; + font-size: 12px; + text-decoration: none; +} diff --git a/template.py b/template.py new file mode 100644 index 0000000..8b0e4ca --- /dev/null +++ b/template.py @@ -0,0 +1,692 @@ +import os, re, StringIO, urllib + +import hyperdb, date + +class Base: + def __init__(self, db, classname, nodeid=None, form=None): + self.db, self.classname, self.nodeid = db, classname, nodeid + self.form = form + self.cl = self.db.classes[self.classname] + self.properties = self.cl.getprops() + +class Plain(Base): + ''' display a String property directly; + + display a Date property in a specified time zone with an option to + omit the time from the date stamp; + + for a Link or Multilink property, display the key strings of the + linked nodes (or the ids if the linked class has no key property) + ''' + def __call__(self, property): + if not self.nodeid and self.form is None: + return '[Field: not called from item]' + propclass = self.properties[property] + if self.nodeid: + value = self.cl.get(self.nodeid, property) + else: + # TODO: pull the value from the form + if propclass.isMultilinkType: value = [] + else: value = '' + if propclass.isStringType: + if value is None: value = '' + else: value = str(value) + elif propclass.isDateType: + value = str(value) + elif propclass.isIntervalType: + value = str(value) + elif propclass.isLinkType: + linkcl = self.db.classes[propclass.classname] + if value: value = str(linkcl.get(value, linkcl.getkey())) + else: value = '[unselected]' + elif propclass.isMultilinkType: + linkcl = self.db.classes[propclass.classname] + k = linkcl.getkey() + value = ', '.join([linkcl.get(i, k) for i in value]) + else: + s = 'Plain: bad propclass "%s"'%propclass + return value + +class Field(Base): + ''' display a property like the plain displayer, but in a text field + to be edited + ''' + def __call__(self, property, size=None, height=None, showid=0): + if not self.nodeid and self.form is None: + return '[Field: not called from item]' + propclass = self.properties[property] + if self.nodeid: + value = self.cl.get(self.nodeid, property) + else: + # TODO: pull the value from the form + if propclass.isMultilinkType: value = [] + else: value = '' + if (propclass.isStringType or propclass.isDateType or + propclass.isIntervalType): + size = size or 30 + if value is None: + value = '' + s = ''%(property, value, size) + elif propclass.isLinkType: + linkcl = self.db.classes[propclass.classname] + l = ['') + s = '\n'.join(l) + elif propclass.isMultilinkType: + linkcl = self.db.classes[propclass.classname] + list = linkcl.list() + height = height or min(len(list), 7) + l = ['') + 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 = ['') + return '\n'.join(l) + if propclass.isMultilinkType: + linkcl = self.db.classes[propclass.classname] + list = linkcl.list() + height = height or min(len(list), 7) + l = ['') + 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 '%s'%(linkcl, value, linkvalue) + if propclass.isMultilinkType: + linkcl = self.db.classes[propclass.classname] + l = [] + for value in value: + linkvalue = linkcl.get(value, k) + l.append('%s'%(linkcl, value, linkvalue)) + return ', '.join(l) + return '%s'%(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 '%s'%(linkcl, value, linkvalue) + if propclass.isMultilinkType: + linkcl = self.db.classes[propclass.classname] + l = [] + for value in value: + linkvalue = linkcl.get(value, k) + l.append('%s'%(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:'%( + 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 ''%(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 = ['', + '', + '', + '', + '', + ''] + + for id, date, user, action, args in self.cl.history(self.nodeid): + l.append(''%( + date, user, action, args)) + l.append('
DateUserActionArgs
%s%s%s%s
') + return '\n'.join(l) + +# XXX new function +class Submit(Base): + ''' add a submit button for the item + ''' + def __call__(self): + if self.nodeid: + return '' + elif self.form is not None: + return '' + 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'(([^>]+)">(?P.+?)
)|' + r'(?P[^"]+)">))', 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']+)">')): + + 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('
') + w('') + w('') + w(' ') + w('') + replace = IndexTemplateReplace(globals, locals(), filter) + w(replace.go(template)) + if columns: + w(''%','.join(columns)) + if filter: + w(''%','.join(filter)) + if sort: + w(''%','.join(sort)) + if group: + w(''%','.join(group)) + for k, v in filterspec.items(): + if type(v) == type([]): v = ','.join(v) + w(''%(k, v)) + w('') + w('') + w('
Filter specification...
 
') + w('
') + + # 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('') + w('') + for name in columns: + cname = name.capitalize() + if show_display_form: + anchor = "%s?%s"%(classname, sortby(name, columns, filter, + sort, group, filterspec)) + w(''%( + anchor, cname)) + else: + w(''%cname) + w('') + + # 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('' + ''%( + 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('
%s%s
%s
') + + if not show_display_form: + return + + # now add in the filter/columns/group/etc config table form + w('

') + w('') + for k,v in filterspec.items(): + if type(v) == type([]): v = ','.join(v) + w(''%(k, v)) + if sort: + w(''%','.join(sort)) + names = [] + for name in cl.getprops().keys(): + if name in all_filters or name in all_columns: + names.append(name) + w('') + w(''% + (len(names)+1)) + w('') + for name in names: + w(''%name.capitalize()) + w('') + + # filter + if all_filters: + w('') + for name in names: + if name not in all_filters: + w('') + continue + if name in filter: checked=' checked' + else: checked='' + w(''%(name, + checked)) + w('') + + # columns + if all_columns: + w('') + for name in names: + if name not in all_columns: + w('') + continue + if name in columns: checked=' checked' + else: checked='' + w(''%( + name, checked)) + w('') + + # group + w('') + for name in names: + prop = properties[name] + if name not in all_columns: + w('') + continue + if name in group: checked=' checked' + else: checked='' + w(''%( + name, checked)) + w('') + + w('') + w('') + w('
View customisation...
 %s
Filters ') + w('
Columns ') + w('
Grouping ') + w('
 '%len(names)) + w('
') + w('
') + + +# +# 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'(([^>]+)">(?P.+?))|' + r'(?P[^"]+)">))', 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[^>]+)">)|' + r'(?P)|' + r'(?P[^"]+)">))', 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('
'%(classname, nodeid)) + s = open(os.path.join('templates', classname+'.item')).read() + replace = ItemTemplateReplace(globals, locals(), cl, nodeid) + w(replace.go(s)) + w('
') + + +def newitem(fp, db, classname, form, replace=re.compile( + r'((?P[^>]+)">)|' + r'(?P)|' + r'(?P[^"]+)">))', 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('
'%classname) + replace = ItemTemplateReplace(globals, locals(), None, None) + w(replace.go(s)) + w('
') + diff --git a/templates/file.index b/templates/file.index new file mode 100644 index 0000000..54e23fb --- /dev/null +++ b/templates/file.index @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/issue.filter b/templates/issue.filter new file mode 100644 index 0000000..0d03fee --- /dev/null +++ b/templates/issue.filter @@ -0,0 +1,36 @@ + + Title + + + + Status + + + + Priority + + + + Platform + + + + Product + + + + Version + + + + Source + + + + Assigned to + + + + Customer name + + diff --git a/templates/issue.index b/templates/issue.index new file mode 100644 index 0000000..b7848f8 --- /dev/null +++ b/templates/issue.index @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/issue.item b/templates/issue.item new file mode 100644 index 0000000..20aa427 --- /dev/null +++ b/templates/issue.item @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Item Information
Title
Product + version:Platform
Created + ()Last activity
PrioritySource
StatusRate
Assigned ToCustomer Name
SupersederNosy List
Change Note
 
Messages
Files
+ diff --git a/templates/msg.index b/templates/msg.index new file mode 100644 index 0000000..06f5472 --- /dev/null +++ b/templates/msg.index @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/templates/msg.item b/templates/msg.item new file mode 100644 index 0000000..2f9bbeb --- /dev/null +++ b/templates/msg.item @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Message Information
Author
Recipients
Date
+
+
Files
History
diff --git a/templates/user.index b/templates/user.index new file mode 100644 index 0000000..a8b101f --- /dev/null +++ b/templates/user.index @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/templates/user.item b/templates/user.item new file mode 100644 index 0000000..53c3775 --- /dev/null +++ b/templates/user.item @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Your Details
Name
Login Name
Login Password
Phone
Organisation
E-mail address
 
History
+