From 262fc7e5638c678edeb5732627a8830975688fa3 Mon Sep 17 00:00:00 2001 From: richard Date: Sun, 22 Jul 2001 12:01:27 +0000 Subject: [PATCH] More Grande Splite git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@29 57a73879-2fb5-44c3-a270-3262357dd7e2 --- roundup/__init__.py | 0 roundup/cgi_client.py | 513 +++++++++++++++++++++++++++ roundup/cgitb.py | 128 +++++++ roundup/date.py | 358 +++++++++++++++++++ roundup/htmltemplate.py | 719 ++++++++++++++++++++++++++++++++++++++ roundup/hyper_bsddb.py | 169 +++++++++ roundup/hyperdb.py | 747 ++++++++++++++++++++++++++++++++++++++++ roundup/init.py | 45 +++ roundup/mailgw.py | 267 ++++++++++++++ roundup/roundupdb.py | 249 ++++++++++++++ templates/__init__.py | 197 +++++++++++ 11 files changed, 3392 insertions(+) create mode 100644 roundup/__init__.py create mode 100644 roundup/cgi_client.py create mode 100644 roundup/cgitb.py create mode 100644 roundup/date.py create mode 100644 roundup/htmltemplate.py create mode 100644 roundup/hyper_bsddb.py create mode 100644 roundup/hyperdb.py create mode 100644 roundup/init.py create mode 100644 roundup/mailgw.py create mode 100644 roundup/roundupdb.py create mode 100644 templates/__init__.py diff --git a/roundup/__init__.py b/roundup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roundup/cgi_client.py b/roundup/cgi_client.py new file mode 100644 index 0000000..8af92a2 --- /dev/null +++ b/roundup/cgi_client.py @@ -0,0 +1,513 @@ +# $Id: cgi_client.py,v 1.1 2001-07-22 11:58:35 richard Exp $ + +import os, cgi, pprint, StringIO, urlparse, re, traceback + +import config, roundupdb, htmltemplate, date + +class Unauthorised(ValueError): + pass + +class Client: + def __init__(self, out, db, env, user): + self.out = out + self.db = db + self.env = env + self.user = user + self.path = env['PATH_INFO'] + self.split_path = self.path.split('/') + + self.headers_done = 0 + self.form = cgi.FieldStorage(environ=env) + 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 = '
%s
'%message + else: + message = '' + style = open(os.path.join(self.TEMPLATES, 'style.css')).read() + userid = self.db.user.lookup(self.user) + if self.user == 'admin': + extras = ' | Class List' + else: + extras = '' + self.write(''' +%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_filterspec(self): + ''' pull the index filter spec from the form + ''' + # 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 + return filterspec + + 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'] + filterspec = self.index_filterspec() + if not filterspec: + filterspec['status'] = ['1', '2', '3', '4', '5', '6', '7'] + return self.list(columns=columns, filter=filter, group=group, + sort=sort, filterspec=filterspec) + + # XXX deviates from spec - loses the '+' (that's a reserved character + # in URLS + def list(self, sort=None, group=None, filter=None, columns=None, + filterspec=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') + if filterspec is None: filterspec = self.index_filterspec() + + htmltemplate.index(self, self.TEMPLATES, 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, generate an edit message + # TODO: don't send the edit message to the person who + # performed the edit + if (cl.getprops().has_key('messages') and + cl.getprops()['messages'].isMultilinkType and + cl.getprops()['messages'].classname == 'msg'): + 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.insert(0, '%s\n\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.insert(0, '%s\n\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 + htmltemplate.item(self, self.TEMPLATES, 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: + m.append('\nThis %s has been created through ' + 'the web.\n'%cn) + + # 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) + htmltemplate.newitem(self, self.TEMPLATES, 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() + +# +# $Log: not supported by cvs2svn $ +# Revision 1.7 2001/07/20 07:35:55 richard +# largish changes as a start of splitting off bits and pieces to allow more +# flexible installation / database back-ends +# +# Revision 1.6 2001/07/20 00:53:20 richard +# Default index now filters out the resolved issues ;) +# +# Revision 1.5 2001/07/20 00:17:16 richard +# Fixed adding a new issue when there is no __note +# +# Revision 1.4 2001/07/19 06:27:07 anthonybaxter +# fixing (manually) the (dollarsign)Log(dollarsign) entries caused by +# my using the magic (dollarsign)Id(dollarsign) and (dollarsign)Log(dollarsign) +# strings in a commit message. I'm a twonk. +# +# Also broke the help string in two. +# +# Revision 1.3 2001/07/19 05:52:22 anthonybaxter +# Added CVS keywords Id and Log to all python files. +# +# + diff --git a/roundup/cgitb.py b/roundup/cgitb.py new file mode 100644 index 0000000..60ef528 --- /dev/null +++ b/roundup/cgitb.py @@ -0,0 +1,128 @@ +# $Id: cgitb.py,v 1.1 2001-07-22 11:58:35 richard Exp $ + +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() + +# +# $Log: not supported by cvs2svn $ +# Revision 1.3 2001/07/19 06:27:07 anthonybaxter +# fixing (manually) the (dollarsign)Log(dollarsign) entries caused by +# my using the magic (dollarsign)Id(dollarsign) and (dollarsign)Log(dollarsign) +# strings in a commit message. I'm a twonk. +# +# Also broke the help string in two. +# +# Revision 1.2 2001/07/19 05:52:22 anthonybaxter +# Added CVS keywords Id and Log to all python files. +# +# diff --git a/roundup/date.py b/roundup/date.py new file mode 100644 index 0000000..e516de2 --- /dev/null +++ b/roundup/date.py @@ -0,0 +1,358 @@ +# $Id: date.py,v 1.1 2001-07-22 11:58:35 richard Exp $ + +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() + +# +# $Log: not supported by cvs2svn $ +# Revision 1.3 2001/07/19 06:27:07 anthonybaxter +# fixing (manually) the (dollarsign)Log(dollarsign) entries caused by +# my using the magic (dollarsign)Id(dollarsign) and (dollarsign)Log(dollarsign) +# strings in a commit message. I'm a twonk. +# +# Also broke the help string in two. +# +# Revision 1.2 2001/07/19 05:52:22 anthonybaxter +# Added CVS keywords Id and Log to all python files. +# +# + diff --git a/roundup/htmltemplate.py b/roundup/htmltemplate.py new file mode 100644 index 0000000..dd17ac2 --- /dev/null +++ b/roundup/htmltemplate.py @@ -0,0 +1,719 @@ +# $Id: htmltemplate.py,v 1.1 2001-07-22 11:58:35 richard Exp $ + +import os, re, StringIO, urllib, cgi + +import hyperdb, date + +class Base: + def __init__(self, db, templates, classname, nodeid=None, form=None): + # TODO: really not happy with the way templates is passed on here + self.db, self.templates = db, templates + self.classname, self.nodeid = 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 = '' + else: + value = cgi.escape(value) + value = '"'.join(value.split('"')) + 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) + # TODO: really not happy with the way templates is passed on here + index(fp, self.templates, 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(client, templates, db, classname, filterspec={}, filter=[], + columns=[], sort=[], group=[], show_display_form=1, nodeids=None, + col_re=re.compile(r']+)">')): + globals = { + 'plain': Plain(db, templates, classname, form={}), + 'field': Field(db, templates, classname, form={}), + 'menu': Menu(db, templates, classname, form={}), + 'link': Link(db, templates, classname, form={}), + 'count': Count(db, templates, classname, form={}), + 'reldate': Reldate(db, templates, classname, form={}), + 'download': Download(db, templates, classname, form={}), + 'checklist': Checklist(db, templates, classname, form={}), + 'list': List(db, templates, classname, form={}), + 'history': History(db, templates, classname, form={}), + 'submit': Submit(db, templates, classname, form={}), + 'note': Note(db, templates, classname, form={}) + } + cl = db.classes[classname] + properties = cl.getprops() + w = client.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: + value = cl.get(nodeid, name) + if value is None: + value = '[empty %s]'%name + l.append(value) + 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(client, templates, db, classname, nodeid, replace=re.compile( + r'((?P[^>]+)">)|' + r'(?P)|' + r'(?P[^"]+)">))', re.I)): + + globals = { + 'plain': Plain(db, templates, classname, nodeid), + 'field': Field(db, templates, classname, nodeid), + 'menu': Menu(db, templates, classname, nodeid), + 'link': Link(db, templates, classname, nodeid), + 'count': Count(db, templates, classname, nodeid), + 'reldate': Reldate(db, templates, classname, nodeid), + 'download': Download(db, templates, classname, nodeid), + 'checklist': Checklist(db, templates, classname, nodeid), + 'list': List(db, templates, classname, nodeid), + 'history': History(db, templates, classname, nodeid), + 'submit': Submit(db, templates, classname, nodeid), + 'note': Note(db, templates, 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 = client.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(client, templates, db, classname, form, replace=re.compile( + r'((?P[^>]+)">)|' + r'(?P)|' + r'(?P[^"]+)">))', re.I)): + globals = { + 'plain': Plain(db, templates, classname, form=form), + 'field': Field(db, templates, classname, form=form), + 'menu': Menu(db, templates, classname, form=form), + 'link': Link(db, templates, classname, form=form), + 'count': Count(db, templates, classname, form=form), + 'reldate': Reldate(db, templates, classname, form=form), + 'download': Download(db, templates, classname, form=form), + 'checklist': Checklist(db, templates, classname, form=form), + 'list': List(db, templates, classname, form=form), + 'history': History(db, templates, classname, form=form), + 'submit': Submit(db, templates, classname, form=form), + 'note': Note(db, templates, classname, form=form) + } + + cl = db.classes[classname] + properties = cl.getprops() + + w = client.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('
') + +# +# $Log: not supported by cvs2svn $ +# Revision 1.5 2001/07/20 07:34:43 richard +# Quote the value put in the text input value attribute. +# +# Revision 1.4 2001/07/19 06:27:07 anthonybaxter +# fixing (manually) the (dollarsign)Log(dollarsign) entries caused by +# my using the magic (dollarsign)Id(dollarsign) and (dollarsign)Log(dollarsign) +# strings in a commit message. I'm a twonk. +# +# Also broke the help string in two. +# +# Revision 1.3 2001/07/19 05:52:22 anthonybaxter +# Added CVS keywords Id and Log to all python files. +# +# + diff --git a/roundup/hyper_bsddb.py b/roundup/hyper_bsddb.py new file mode 100644 index 0000000..996319e --- /dev/null +++ b/roundup/hyper_bsddb.py @@ -0,0 +1,169 @@ +#$Id: hyper_bsddb.py,v 1.1 2001-07-22 11:58:35 richard Exp $ + +import bsddb, os, cPickle +import hyperdb, date + +# +# Now the database +# +class Database(hyperdb.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 + ''' + +# +#$Log: not supported by cvs2svn $ +#Revision 1.1 2001/07/20 07:35:55 richard +#largish changes as a start of splitting off bits and pieces to allow more +#flexible installation / database back-ends +# + diff --git a/roundup/hyperdb.py b/roundup/hyperdb.py new file mode 100644 index 0000000..5dfb416 --- /dev/null +++ b/roundup/hyperdb.py @@ -0,0 +1,747 @@ +# $Id: hyperdb.py,v 1.1 2001-07-22 11:58:35 richard Exp $ + +# standard python modules +import cPickle, re, string + +# roundup modules +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 + + +# +# the base Database class +# +class Database: + # flag to set on retired entries + RETIRED_FLAG = '__hyperdb_retired' + + +# +# The base Class class +# +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(self.db.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[self.db.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(self.db.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(self.db.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(self.db.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(self.db.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'%( + k, entry, self.properties[k].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'%( + k, entry, self.properties[k].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.replace('*', '.*?') + v = re.compile(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(self.db.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.search(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: + # clean up the strings + if av and av[0] in string.uppercase: + av = an[prop] = av.lower() + if bv and 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) + +# +# $Log: not supported by cvs2svn $ +# Revision 1.6 2001/07/20 08:20:24 richard +# Fixed a bug in the filter - wrong variable names in the error message. +# Recognised that the filter has an outstanding bug. Hrm. we need a bug tracker +# for this project :) +# +# Revision 1.5 2001/07/20 07:35:55 richard +# largish changes as a start of splitting off bits and pieces to allow more +# flexible installation / database back-ends +# + diff --git a/roundup/init.py b/roundup/init.py new file mode 100644 index 0000000..4174afc --- /dev/null +++ b/roundup/init.py @@ -0,0 +1,45 @@ +import os, shutil, sys + +def copytree(src, dst, symlinks=0): + """Recursively copy a directory tree using copy2(). + + The destination directory os allowed to exist. + + If the optional symlinks flag is true, symbolic links in the + source tree result in symbolic links in the destination tree; if + it is false, the contents of the files pointed to by symbolic + links are copied. + + XXX copied from shutil.py in std lib + + """ + names = os.listdir(src) + try: + os.mkdir(dst) + except OSError, error: + if error.errno != 17: raise + for name in names: + srcname = os.path.join(src, name) + dstname = os.path.join(dst, name) + if symlinks and os.path.islink(srcname): + linkto = os.readlink(srcname) + os.symlink(linkto, dstname) + elif os.path.isdir(srcname): + copytree(srcname, dstname, symlinks) + else: + shutil.copy2(srcname, dstname) + +def init(instance, template, adminpw): + ''' initialise an instance using the named template + ''' + # first, copy the template dir over + template_dir = os.path.split(__file__)[0] + template = os.path.join(template_dir, 'templates', template) + copytree(template, instance) + + # now import the instance and call its init + path, instance = os.path.split(instance) + sys.path.insert(0, path) + instance = __import__(instance) + instance.init(adminpw) + diff --git a/roundup/mailgw.py b/roundup/mailgw.py new file mode 100644 index 0000000..52e4057 --- /dev/null +++ b/roundup/mailgw.py @@ -0,0 +1,267 @@ +''' +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. + +$Id: mailgw.py,v 1.1 2001-07-22 11:58:35 richard Exp $ +''' + + +import string, re, os, mimetools, StringIO, smtplib, socket, binascii, quopri +import traceback +import date + +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) + +class MailGW: + def __init__(self, db): + self.db = db + + def main(self, fp): + # ok, figure the subject, author, recipients and content-type + message = mimetools.Message(fp) + try: + self.handle_message(message) + except: + # bounce the message back to the sender with the error message + sendto = [message.getaddrlist('from')[0][1]] + m = ['Subject: failed issue tracker submission'] + m.append('') + # TODO as attachments? + m.append('---- traceback of failure ----') + 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(self.MAILHOST) + smtp.sendmail(self.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(self, 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 = self.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 = self.db.uidFromAddress(message.getaddrlist('from')[0]) + recipients = [] + for recipient in message.getaddrlist('to') + message.getaddrlist('cc'): + if recipient[1].strip().lower() == self.ISSUE_TRACKER_EMAIL: + continue + recipients.append(self.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(self.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 = self.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 = self.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 = cl.create(**props) + diff --git a/roundup/roundupdb.py b/roundup/roundupdb.py new file mode 100644 index 0000000..b27493a --- /dev/null +++ b/roundup/roundupdb.py @@ -0,0 +1,249 @@ +# $Id: roundupdb.py,v 1.1 2001-07-22 11:58:35 richard Exp $ + +import re, os, smtplib, socket + +import 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: + 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) + + user is created if they don't exist in the db already + ''' + (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 - was called ItemClass +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'%self.ISSUE_TRACKER_EMAIL) + m.append('') + m.append(self.db.msg.get(msgid, 'content')) + # TODO attachments + try: + smtp = smtplib.SMTP(self.MAILHOST) + smtp.sendmail(self.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 + +# +# $Log: not supported by cvs2svn $ +# Revision 1.6 2001/07/20 07:35:55 richard +# largish changes as a start of splitting off bits and pieces to allow more +# flexible installation / database back-ends +# +# Revision 1.5 2001/07/20 00:22:50 richard +# Priority list changes - removed the redundant TODO and added support. See +# roundup-devel for details. +# +# Revision 1.4 2001/07/19 06:27:07 anthonybaxter +# fixing (manually) the (dollarsign)Log(dollarsign) entries caused by +# my using the magic (dollarsign)Id(dollarsign) and (dollarsign)Log(dollarsign) +# strings in a commit message. I'm a twonk. +# +# Also broke the help string in two. +# +# Revision 1.3 2001/07/19 05:52:22 anthonybaxter +# Added CVS keywords Id and Log to all python files. +# +# + diff --git a/templates/__init__.py b/templates/__init__.py new file mode 100644 index 0000000..e04751e --- /dev/null +++ b/templates/__init__.py @@ -0,0 +1,197 @@ +# $Id: __init__.py,v 1.1 2001-07-22 12:01:27 richard Exp $ + +MAIL_DOMAIN=MAILHOST=HTTP_HOST=None +HTTP_PORT=0 + +try: + from localconfig import * +except ImportError: + localconfig = None + +import os + +# roundup home is this package's directory +ROUNDUP_HOME=os.path.split(__file__)[0] + +# The SMTP mail host that roundup will use to send mail +if not MAILHOST: + MAILHOST = 'localhost' + +# The domain name used for email addresses. +if not MAIL_DOMAIN: + MAIL_DOMAIN = 'bizarsoftware.com.au' + +# the next two are only used for the standalone HTTP server. +if not HTTP_HOST: + HTTP_HOST = '' +if not HTTP_PORT: + HTTP_PORT = 9080 + +# This is the directory that the database is going to be stored in +DATABASE = os.path.join(ROUNDUP_HOME, 'db') + +# This is the directory that the HTML templates reside in +TEMPLATES = os.path.join(ROUNDUP_HOME, 'templates') + +# The email address that mail to roundup should go to +ISSUE_TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN + +# The email address that roundup will complain to if it runs into trouble +ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN + +# Somewhere for roundup to log stuff internally sent to stdout or stderr +LOG = os.path.join(ROUNDUP_HOME, 'roundup.log') + + +from roundup import hyperdb, hyper_bsddb, roundupdb, cgi_client, mailgw + +class Database(roundupdb.Database, hyper_bsddb.Database): + ''' Creates a hybrid database from: + . the base Database class given in hyperdb (basic functionlity) + . the BSDDB implementation in hyperdb_bsddb + . the roundup extensions from roundupdb + ''' + pass + +Class = roundupdb.Class +class IssueClass(roundupdb.IssueClass): + ''' issues need the email information + ''' + ISSUE_TRACKER_EMAIL = ISSUE_TRACKER_EMAIL + ADMIN_EMAIL = ADMIN_EMAIL + MAILHOST = MAILHOST + +FileClass = roundupdb.FileClass + +class Client(cgi_client.Client): + ''' derives basic mail gateway implementation from the standard module, + with any specific extensions + ''' + TEMPLATES = TEMPLATES + pass + +class MailGW(mailgw.MailGW): + ''' derives basic mail gateway implementation from the standard module, + with any specific extensions + ''' + ISSUE_TRACKER_EMAIL = ISSUE_TRACKER_EMAIL + ADMIN_EMAIL = ADMIN_EMAIL + MAILHOST = MAILHOST + +def open(name=None): + ''' as from the roundupdb method openDB + + storagelocator must be the directory the __init__.py file is in + - os.path.split(__file__)[0] gives us that I think + ''' + db = Database(DATABASE, 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') + import detectors + detectors.init(db) + return db + +def init(adminpw): + ''' as from the roundupdb method initDB + + storagelocator must be the directory the __init__.py file is in + - os.path.split(__file__)[0] gives us that I think + ''' + dbdir = os.path.join(DATABASE, 'files') + if not os.path.isdir(dbdir): + os.makedirs(dbdir) + db = open("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") + pri.create(name="support", order="5") + + 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=adminpw, address=ADMIN_EMAIL) + + db.close() + +# +# $Log: not supported by cvs2svn $ +# Revision 1.6 2001/07/19 10:43:01 anthonybaxter +# HTTP_HOST and HTTP_PORT config options. +# +# Revision 1.5 2001/07/19 06:27:07 anthonybaxter +# fixing (manually) the (dollarsign)Log(dollarsign) entries caused by +# my using the magic (dollarsign)Id(dollarsign) and (dollarsign)Log(dollarsign) +# strings in a commit message. I'm a twonk. +# +# Also broke the help string in two. +# +# Revision 1.4 2001/07/19 05:52:22 anthonybaxter +# Added CVS keywords Id and Log to all python files. +# +# + + -- 2.30.2