summary | shortlog | log | commit | commitdiff | tree
raw | patch | inline | side by side (parent: 33fefc9)
raw | patch | inline | side by side (parent: 33fefc9)
author | richard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2> | |
Sun, 22 Jul 2001 12:01:27 +0000 (12:01 +0000) | ||
committer | richard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2> | |
Sun, 22 Jul 2001 12:01:27 +0000 (12:01 +0000) |
git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@29 57a73879-2fb5-44c3-a270-3262357dd7e2
roundup/__init__.py | [new file with mode: 0644] | patch | blob |
roundup/cgi_client.py | [new file with mode: 0644] | patch | blob |
roundup/cgitb.py | [new file with mode: 0644] | patch | blob |
roundup/date.py | [new file with mode: 0644] | patch | blob |
roundup/htmltemplate.py | [new file with mode: 0644] | patch | blob |
roundup/hyper_bsddb.py | [new file with mode: 0644] | patch | blob |
roundup/hyperdb.py | [new file with mode: 0644] | patch | blob |
roundup/init.py | [new file with mode: 0644] | patch | blob |
roundup/mailgw.py | [new file with mode: 0644] | patch | blob |
roundup/roundupdb.py | [new file with mode: 0644] | patch | blob |
templates/__init__.py | [new file with mode: 0644] | patch | blob |
diff --git a/roundup/__init__.py b/roundup/__init__.py
diff --git a/roundup/cgi_client.py b/roundup/cgi_client.py
--- /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 = '<div class="system-msg">%s</div>'%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 = ' | <a href="list_classes">Class List</a>'
+ else:
+ extras = ''
+ self.write('''<html><head>
+<title>%s</title>
+<style type="text/css">%s</style>
+</head>
+<body bgcolor=#ffffff>
+%s
+<table width=100%% border=0 cellspacing=0 cellpadding=2>
+<tr class="location-bar"><td><big><strong>%s</strong></big></td>
+<td align=right valign=bottom>%s</td></tr>
+<tr class="location-bar">
+<td align=left><a href="issue?status=unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:columns=activity,status,title&:group=priority">All issues</a> |
+<a href="issue?priority=fatal-bug,bug">Bugs</a> |
+<a href="issue?priority=usability">Support</a> |
+<a href="issue?priority=feature">Wishlist</a> |
+<a href="newissue">New Issue</a>
+%s</td>
+<td align=right><a href="user%s">Your Details</a></td>
+</table>
+'''%(title, style, message, title, self.user, extras, userid))
+
+ def pagefoot(self):
+ if self.debug:
+ self.write('<hr><small><dl>')
+ self.write('<dt><b>Path</b></dt>')
+ self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
+ keys = self.form.keys()
+ keys.sort()
+ if keys:
+ self.write('<dt><b>Form entries</b></dt>')
+ for k in self.form.keys():
+ v = str(self.form[k].value)
+ self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
+ keys = self.env.keys()
+ keys.sort()
+ self.write('<dt><b>CGI environment</b></dt>')
+ for k in keys:
+ v = self.env[k]
+ self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
+ self.write('</dl></small>')
+ self.write('</body></html>')
+
+ def write(self, content):
+ if not self.headers_done:
+ self.header()
+ self.out.write(content)
+
+ def index_arg(self, arg):
+ ''' handle the args to index - they might be a list from the form
+ (ie. submitted from a form) or they might be a command-separated
+ single string (ie. manually constructed GET args)
+ '''
+ if self.form.has_key(arg):
+ arg = self.form[arg]
+ if type(arg) == type([]):
+ return [arg.value for arg in arg]
+ return arg.value.split(',')
+ return []
+
+ def index_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 = '<pre>%s</pre>'%cgi.escape(s.getvalue())
+
+ # now the display
+ id = self.nodeid
+ if cl.getkey():
+ id = cl.get(id, cl.getkey())
+ self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
+
+ nodeid = self.nodeid
+
+ # use the template to display the item
+ 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 = '<pre>%s</pre>'%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('<table border=0 cellspacing=0 cellpadding=2>\n')
+ for cn in classnames:
+ cl = self.db.getclass(cn)
+ self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
+ for key, value in cl.properties.items():
+ if value is None: value = ''
+ else: value = str(value)
+ self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
+ key, cgi.escape(value)))
+ self.write('</table>')
+ self.pagefoot()
+ else:
+ raise Unauthorised
+
+ def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')):
+ path = self.split_path
+ if not path or path[0] in ('', 'index'):
+ self.index()
+ elif len(path) == 1:
+ if path[0] == 'list_classes':
+ self.classes()
+ return
+ m = dre.match(path[0])
+ if m:
+ self.classname = m.group(1)
+ self.nodeid = m.group(2)
+ getattr(self, 'show%s'%self.classname)()
+ return
+ m = nre.match(path[0])
+ if m:
+ self.classname = m.group(1)
+ getattr(self, 'new%s'%self.classname)()
+ return
+ self.classname = path[0]
+ self.list()
+ else:
+ raise 'ValueError', 'Path not understood'
+
+ def __del__(self):
+ self.db.close()
+
+#
+# $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
--- /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 ('<body bgcolor="#f0f0ff">' +
+ '<font color="#f0f0ff" size="-5"> > </font> ' +
+ '</table>' * 5)
+
+def html(context=5):
+ etype, evalue = sys.exc_type, sys.exc_value
+ if type(etype) is types.ClassType:
+ etype = etype.__name__
+ pyver = 'Python ' + string.split(sys.version)[0] + '<br>' + sys.executable
+ head = pydoc.html.heading(
+ '<font size=+1><strong>%s</strong>: %s</font>'%(str(etype), str(evalue)),
+ '#ffffff', '#aa55cc', pyver)
+
+ head = head + ('<p>A problem occurred while running a Python script. '
+ 'Here is the sequence of function calls leading up to '
+ 'the error, with the most recent (innermost) call first. '
+ 'The exception attributes are:')
+
+ indent = '<tt><small>%s</small> </tt>' % (' ' * 5)
+ traceback = []
+ for frame, file, lnum, func, lines, index in inspect.trace(context):
+ if file is None:
+ link = '<file is None - probably inside <tt>eval</tt> or <tt>exec</tt>>'
+ else:
+ file = os.path.abspath(file)
+ link = '<a href="file:%s">%s</a>' % (file, pydoc.html.escape(file))
+ args, varargs, varkw, locals = inspect.getargvalues(frame)
+ if func == '?':
+ call = ''
+ else:
+ call = 'in <strong>%s</strong>' % func + inspect.formatargvalues(
+ args, varargs, varkw, locals,
+ formatvalue=lambda value: '=' + pydoc.html.repr(value))
+
+ level = '''
+<table width="100%%" bgcolor="#d8bbff" cellspacing=0 cellpadding=2 border=0>
+<tr><td>%s %s</td></tr></table>''' % (link, call)
+
+ if file is None:
+ traceback.append('<p>' + level)
+ continue
+
+ # do a fil inspection
+ names = []
+ def tokeneater(type, token, start, end, line, names=names):
+ if type == tokenize.NAME and token not in keyword.kwlist:
+ if token not in names:
+ names.append(token)
+ if type == tokenize.NEWLINE: raise IndexError
+ def linereader(file=file, lnum=[lnum]):
+ line = linecache.getline(file, lnum[0])
+ lnum[0] = lnum[0] + 1
+ return line
+
+ try:
+ tokenize.tokenize(linereader, tokeneater)
+ except IndexError: pass
+ lvals = []
+ for name in names:
+ if name in frame.f_code.co_varnames:
+ if locals.has_key(name):
+ value = pydoc.html.repr(locals[name])
+ else:
+ value = '<em>undefined</em>'
+ name = '<strong>%s</strong>' % name
+ else:
+ if frame.f_globals.has_key(name):
+ value = pydoc.html.repr(frame.f_globals[name])
+ else:
+ value = '<em>undefined</em>'
+ name = '<em>global</em> <strong>%s</strong>' % name
+ lvals.append('%s = %s' % (name, value))
+ if lvals:
+ lvals = string.join(lvals, ', ')
+ lvals = indent + '''
+<small><font color="#909090">%s</font></small><br>''' % lvals
+ else:
+ lvals = ''
+
+ excerpt = []
+ i = lnum - index
+ for line in lines:
+ number = ' ' * (5-len(str(i))) + str(i)
+ number = '<small><font color="#909090">%s</font></small>' % number
+ line = '<tt>%s %s</tt>' % (number, pydoc.html.preformat(line))
+ if i == lnum:
+ line = '''
+<table width="100%%" bgcolor="#ffccee" cellspacing=0 cellpadding=0 border=0>
+<tr><td>%s</td></tr></table>''' % line
+ excerpt.append('\n' + line)
+ if i == lnum:
+ excerpt.append(lvals)
+ i = i + 1
+ traceback.append('<p>' + level + string.join(excerpt, '\n'))
+
+ traceback.reverse()
+
+ exception = '<p><strong>%s</strong>: %s' % (str(etype), str(evalue))
+ attribs = []
+ if type(evalue) is types.InstanceType:
+ for name in dir(evalue):
+ value = pydoc.html.repr(getattr(evalue, name))
+ attribs.append('<br>%s%s = %s' % (indent, name, value))
+
+ return head + string.join(attribs) + string.join(traceback) + '<p> </p>'
+
+def handler():
+ print breaker()
+ print html()
+
+#
+# $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
--- /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 <Date 2000-04-17.00:00:00>
+ "01-25" means <Date yyyy-01-25.00:00:00>
+ "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+ "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+ "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+ "14:25" means <Date yyyy-mm-dd.19:25:00>
+ "8:47:11" means <Date yyyy-mm-dd.13:47:11>
+ "." means "right now"
+
+ The Date class should understand simple date expressions of the form
+ stamp + interval and stamp - interval. When adding or subtracting
+ intervals involving months or years, the components are handled
+ separately. For example, when evaluating "2000-06-25 + 1m 10d", we
+ first add one month to get 2000-07-25, then add 10 days to get
+ 2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40
+ or 41 days).
+
+ Example usage:
+ >>> Date(".")
+ <Date 2000-06-26.00:34:02>
+ >>> _.local(-5)
+ "2000-06-25.19:34:02"
+ >>> Date(". + 2d")
+ <Date 2000-06-28.00:34:02>
+ >>> Date("1997-04-17", -5)
+ <Date 1997-04-17.00:00:00>
+ >>> Date("01-25", -5)
+ <Date 2000-01-25.00:00:00>
+ >>> Date("08-13.22:13", -5)
+ <Date 2000-08-14.03:13:00>
+ >>> Date("14:25", -5)
+ <Date 2000-06-25.19:25:00>
+ '''
+ isDate = 1
+
+ def __init__(self, spec='.', offset=0, set=None):
+ """Construct a date given a specification and a time zone offset.
+
+ 'spec' is a full date or a partial form, with an optional
+ added or subtracted interval.
+ 'offset' is the local time zone offset from GMT in hours.
+ """
+ if set is None:
+ self.set(spec, offset=offset)
+ else:
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second, x, x, x = set
+ self.offset = offset
+
+ def applyInterval(self, interval):
+ ''' Apply the interval to this date
+ '''
+ t = (self.year + interval.year,
+ self.month + interval.month,
+ self.day + interval.day,
+ self.hour + interval.hour,
+ self.minute + interval.minute,
+ self.second + interval.second, 0, 0, 0)
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second, x, x, x = time.gmtime(calendar.timegm(t))
+
+ def __add__(self, other):
+ """Add an interval to this date to produce another date."""
+ t = (self.year + other.sign * other.year,
+ self.month + other.sign * other.month,
+ self.day + other.sign * other.day,
+ self.hour + other.sign * other.hour,
+ self.minute + other.sign * other.minute,
+ self.second + other.sign * other.second, 0, 0, 0)
+ return Date(set = time.gmtime(calendar.timegm(t)))
+
+ # XXX deviates from spec to allow subtraction of dates as well
+ def __sub__(self, other):
+ """ Subtract:
+ 1. an interval from this date to produce another date.
+ 2. a date from this date to produce an interval.
+ """
+ if other.isDate:
+ # TODO this code will fall over laughing if the dates cross
+ # leap years, phases of the moon, ....
+ a = calendar.timegm((self.year, self.month, self.day, self.hour,
+ self.minute, self.second, 0, 0, 0))
+ b = calendar.timegm((other.year, other.month, other.day, other.hour,
+ other.minute, other.second, 0, 0, 0))
+ diff = a - b
+ if diff < 0:
+ sign = -1
+ diff = -diff
+ else:
+ sign = 1
+ S = diff%60
+ M = (diff/60)%60
+ H = (diff/(60*60))%60
+ if H>1: S = 0
+ d = (diff/(24*60*60))%30
+ if d>1: H = S = M = 0
+ m = (diff/(30*24*60*60))%12
+ if m>1: H = S = M = 0
+ y = (diff/(365*24*60*60))
+ if y>1: d = H = S = M = 0
+ return Interval((y, m, d, H, M, S), sign=sign)
+ t = (self.year - other.sign * other.year,
+ self.month - other.sign * other.month,
+ self.day - other.sign * other.day,
+ self.hour - other.sign * other.hour,
+ self.minute - other.sign * other.minute,
+ self.second - other.sign * other.second, 0, 0, 0)
+ return Date(set = time.gmtime(calendar.timegm(t)))
+
+ def __cmp__(self, other):
+ """Compare this date to another date."""
+ for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+ r = cmp(getattr(self, attr), getattr(other, attr))
+ if r: return r
+ return 0
+
+ def __str__(self):
+ """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
+ return time.strftime('%Y-%m-%d.%T', (self.year, self.month,
+ self.day, self.hour, self.minute, self.second, 0, 0, 0))
+
+ def pretty(self):
+ ''' print up the date date using a pretty format...
+ '''
+ return time.strftime('%e %B %Y', (self.year, self.month,
+ self.day, self.hour, self.minute, self.second, 0, 0, 0))
+
+ def set(self, spec, offset=0, date_re=re.compile(r'''
+ (((?P<y>\d\d\d\d)-)?((?P<m>\d\d)-(?P<d>\d\d))?)? # yyyy-mm-dd
+ (?P<n>\.)? # .
+ (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)? # hh:mm:ss
+ (?P<o>.+)? # offset
+ ''', re.VERBOSE)):
+ ''' set the date to the value in spec
+ '''
+ m = date_re.match(spec)
+ if not m:
+ raise ValueError, 'Not a date spec: [[yyyy-]mm-dd].[[h]h:mm[:ss]] [offset]'
+ info = m.groupdict()
+
+ # get the current date/time using the offset
+ y,m,d,H,M,S,x,x,x = time.gmtime(time.time())
+ ts = calendar.timegm((y,m,d,H+offset,M,S,0,0,0))
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second, x, x, x = time.gmtime(ts)
+
+ if info['m'] is not None and info['d'] is not None:
+ self.month = int(info['m'])
+ self.day = int(info['d'])
+ if info['y'] is not None:
+ self.year = int(info['y'])
+ self.hour = self.minute = self.second = 0
+
+ if info['H'] is not None and info['M'] is not None:
+ self.hour = int(info['H'])
+ self.minute = int(info['M'])
+ if info['S'] is not None:
+ self.second = int(info['S'])
+
+ if info['o']:
+ self.applyInterval(Interval(info['o']))
+
+ def __repr__(self):
+ return '<Date %s>'%self.__str__()
+
+ def local(self, offset):
+ """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone."""
+ t = (self.year, self.month, self.day, self.hour + offset, self.minute,
+ self.second, 0, 0, 0)
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second, x, x, x = time.gmtime(calendar.timegm(t))
+
+
+class Interval:
+ '''
+ Date intervals are specified using the suffixes "y", "m", and "d". The
+ suffix "w" (for "week") means 7 days. Time intervals are specified in
+ hh:mm:ss format (the seconds may be omitted, but the hours and minutes
+ may not).
+
+ "3y" means three years
+ "2y 1m" means two years and one month
+ "1m 25d" means one month and 25 days
+ "2w 3d" means two weeks and three days
+ "1d 2:50" means one day, two hours, and 50 minutes
+ "14:00" means 14 hours
+ "0:04:33" means four minutes and 33 seconds
+
+ Example usage:
+ >>> Interval(" 3w 1 d 2:00")
+ <Interval 22d 2:00>
+ >>> Date(". + 2d") - Interval("3w")
+ <Date 2000-06-07.00:34:02>
+ '''
+ isInterval = 1
+
+ def __init__(self, spec, sign=1):
+ """Construct an interval given a specification."""
+ if type(spec) == type(''):
+ self.set(spec)
+ else:
+ self.sign = sign
+ self.year, self.month, self.day, self.hour, self.minute, \
+ self.second = spec
+
+ def __cmp__(self, other):
+ """Compare this interval to another interval."""
+ for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+ r = cmp(getattr(self, attr), getattr(other, attr))
+ if r: return r
+ return 0
+
+ def __str__(self):
+ """Return this interval as a string."""
+ sign = {1:'+', -1:'-'}[self.sign]
+ l = [sign]
+ if self.year: l.append('%sy'%self.year)
+ if self.month: l.append('%sm'%self.month)
+ if self.day: l.append('%sd'%self.day)
+ if self.second:
+ l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
+ elif self.hour or self.minute:
+ l.append('%d:%02d'%(self.hour, self.minute))
+ return ' '.join(l)
+
+ def set(self, spec, interval_re = re.compile('''
+ \s*
+ (?P<s>[-+])? # + or -
+ \s*
+ ((?P<y>\d+\s*)y)? # year
+ \s*
+ ((?P<m>\d+\s*)m)? # month
+ \s*
+ ((?P<w>\d+\s*)w)? # week
+ \s*
+ ((?P<d>\d+\s*)d)? # day
+ \s*
+ (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)? # time
+ \s*
+ ''', re.VERBOSE)):
+ ''' set the date to the value in spec
+ '''
+ self.year = self.month = self.week = self.day = self.hour = \
+ self.minute = self.second = 0
+ self.sign = 1
+ m = interval_re.match(spec)
+ if not m:
+ raise ValueError, 'Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]'
+
+ info = m.groupdict()
+ for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
+ 'H':'hour', 'M':'minute', 'S':'second'}.items():
+ if info[group] is not None:
+ setattr(self, attr, int(info[group]))
+
+ if self.week:
+ self.day = self.day + self.week*7
+
+ if info['s'] is not None:
+ self.sign = {'+':1, '-':-1}[info['s']]
+
+ def __repr__(self):
+ return '<Interval %s>'%self.__str__()
+
+ def pretty(self, threshold=('d', 5)):
+ ''' print up the date date using one of these nice formats..
+ < 1 minute
+ < 15 minutes
+ < 30 minutes
+ < 1 hour
+ < 12 hours
+ < 1 day
+ otherwise, return None (so a full date may be displayed)
+ '''
+ if self.year or self.month or self.day > 5:
+ return None
+ if self.day > 1:
+ return '%s days'%self.day
+ if self.day == 1 or self.hour > 12:
+ return 'yesterday'
+ if self.hour > 1:
+ return '%s hours'%self.hour
+ if self.hour == 1:
+ if self.minute < 15:
+ return 'an hour'
+ quart = self.minute/15
+ if quart == 2:
+ return '1 1/2 hours'
+ return '1 %s/4 hours'%quart
+ if self.minute < 1:
+ return 'just now'
+ if self.minute == 1:
+ return '1 minute'
+ if self.minute < 15:
+ return '%s minutes'%self.minute
+ quart = self.minute/15
+ if quart == 2:
+ return '1/2 an hour'
+ return '%s/4 hour'%quart
+
+
+def test():
+ intervals = (" 3w 1 d 2:00", " + 2d", "3w")
+ for interval in intervals:
+ print '>>> Interval("%s")'%interval
+ print `Interval(interval)`
+
+ dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
+ "08-13.22:13", "14:25")
+ for date in dates:
+ print '>>> Date("%s")'%date
+ print `Date(date)`
+
+ sums = ((". + 2d", "3w"), (".", " 3w 1 d 2:00"))
+ for date, interval in sums:
+ print '>>> Date("%s") + Interval("%s")'%(date, interval)
+ print `Date(date) + Interval(interval)`
+
+if __name__ == '__main__':
+ test()
+
+#
+# $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
--- /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 = '<input name="%s" value="%s" size="%s">'%(property, value, size)
+ elif propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = ['<select name="%s">'%property]
+ k = linkcl.getkey()
+ for optionid in linkcl.list():
+ option = linkcl.get(optionid, k)
+ s = ''
+ if optionid == value:
+ s = 'selected '
+ if showid:
+ lab = '%s%s: %s'%(propclass.classname, optionid, option)
+ else:
+ lab = option
+ if size is not None and len(lab) > size:
+ lab = lab[:size-3] + '...'
+ l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+ l.append('</select>')
+ s = '\n'.join(l)
+ elif propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ list = linkcl.list()
+ height = height or min(len(list), 7)
+ l = ['<select multiple name="%s" size="%s">'%(property, height)]
+ k = linkcl.getkey()
+ for optionid in list:
+ option = linkcl.get(optionid, k)
+ s = ''
+ if optionid in value:
+ s = 'selected '
+ if showid:
+ lab = '%s%s: %s'%(propclass.classname, optionid, option)
+ else:
+ lab = option
+ if size is not None and len(lab) > size:
+ lab = lab[:size-3] + '...'
+ l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+ l.append('</select>')
+ s = '\n'.join(l)
+ else:
+ s = 'Plain: bad propclass "%s"'%propclass
+ return s
+
+class Menu(Base):
+ ''' for a Link property, display a menu of the available choices
+ '''
+ def __call__(self, property, size=None, height=None, showid=0):
+ propclass = self.properties[property]
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ # TODO: pull the value from the form
+ if propclass.isMultilinkType: value = []
+ else: value = None
+ if propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = ['<select name="%s">'%property]
+ k = linkcl.getkey()
+ for optionid in linkcl.list():
+ option = linkcl.get(optionid, k)
+ s = ''
+ if optionid == value:
+ s = 'selected '
+ l.append('<option %svalue="%s">%s</option>'%(s, optionid, option))
+ l.append('</select>')
+ return '\n'.join(l)
+ if propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ list = linkcl.list()
+ height = height or min(len(list), 7)
+ l = ['<select multiple name="%s" size="%s">'%(property, height)]
+ k = linkcl.getkey()
+ for optionid in list:
+ option = linkcl.get(optionid, k)
+ s = ''
+ if optionid in value:
+ s = 'selected '
+ if showid:
+ lab = '%s%s: %s'%(propclass.classname, optionid, option)
+ else:
+ lab = option
+ if size is not None and len(lab) > size:
+ lab = lab[:size-3] + '...'
+ l.append('<option %svalue="%s">%s</option>'%(s, optionid, option))
+ l.append('</select>')
+ return '\n'.join(l)
+ return '[Menu: not a link]'
+
+#XXX deviates from spec
+class Link(Base):
+ ''' for a Link or Multilink property, display the names of the linked
+ nodes, hyperlinked to the item views on those nodes
+ for other properties, link to this node with the property as the text
+ '''
+ def __call__(self, property=None, **args):
+ if not self.nodeid and self.form is None:
+ return '[Link: not called from item]'
+ propclass = self.properties[property]
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ if propclass.isMultilinkType: value = []
+ else: value = ''
+ if propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ linkvalue = linkcl.get(value, k)
+ return '<a href="%s%s">%s</a>'%(linkcl, value, linkvalue)
+ if propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = []
+ for value in value:
+ linkvalue = linkcl.get(value, k)
+ l.append('<a href="%s%s">%s</a>'%(linkcl, value, linkvalue))
+ return ', '.join(l)
+ return '<a href="%s%s">%s</a>'%(self.classname, self.nodeid, value)
+
+class Count(Base):
+ ''' for a Multilink property, display a count of the number of links in
+ the list
+ '''
+ def __call__(self, property, **args):
+ if not self.nodeid:
+ return '[Count: not called from item]'
+ propclass = self.properties[property]
+ value = self.cl.get(self.nodeid, property)
+ if propclass.isMultilinkType:
+ return str(len(value))
+ return '[Count: not a Multilink]'
+
+# XXX pretty is definitely new ;)
+class Reldate(Base):
+ ''' display a Date property in terms of an interval relative to the
+ current date (e.g. "+ 3w", "- 2d").
+
+ with the 'pretty' flag, make it pretty
+ '''
+ def __call__(self, property, pretty=0):
+ if not self.nodeid and self.form is None:
+ return '[Reldate: not called from item]'
+ propclass = self.properties[property]
+ if not propclass.isDateType:
+ return '[Reldate: not a Date]'
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ value = date.Date('.')
+ interval = value - date.Date('.')
+ if pretty:
+ if not self.nodeid:
+ return 'now'
+ pretty = interval.pretty()
+ if pretty is None:
+ pretty = value.pretty()
+ return pretty
+ return str(interval)
+
+class Download(Base):
+ ''' show a Link("file") or Multilink("file") property using links that
+ allow you to download files
+ '''
+ def __call__(self, property, **args):
+ if not self.nodeid:
+ return '[Download: not called from item]'
+ propclass = self.properties[property]
+ value = self.cl.get(self.nodeid, property)
+ if propclass.isLinkType:
+ linkcl = self.db.classes[propclass.classname]
+ linkvalue = linkcl.get(value, k)
+ return '<a href="%s%s">%s</a>'%(linkcl, value, linkvalue)
+ if propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = []
+ for value in value:
+ linkvalue = linkcl.get(value, k)
+ l.append('<a href="%s%s">%s</a>'%(linkcl, value, linkvalue))
+ return ', '.join(l)
+ return '[Download: not a link]'
+
+
+class Checklist(Base):
+ ''' for a Link or Multilink property, display checkboxes for the available
+ choices to permit filtering
+ '''
+ def __call__(self, property, **args):
+ propclass = self.properties[property]
+ if self.nodeid:
+ value = self.cl.get(self.nodeid, property)
+ else:
+ value = []
+ if propclass.isLinkType or propclass.isMultilinkType:
+ linkcl = self.db.classes[propclass.classname]
+ l = []
+ k = linkcl.getkey()
+ for optionid in linkcl.list():
+ option = linkcl.get(optionid, k)
+ if optionid in value:
+ checked = 'checked'
+ else:
+ checked = ''
+ l.append('%s:<input type="checkbox" %s name="%s" value="%s">'%(
+ option, checked, propclass.classname, option))
+ return '\n'.join(l)
+ return '[Checklist: not a link]'
+
+class Note(Base):
+ ''' display a "note" field, which is a text area for entering a note to
+ go along with a change.
+ '''
+ def __call__(self, rows=5, cols=80):
+ # TODO: pull the value from the form
+ return '<textarea name="__note" rows=%s cols=%s></textarea>'%(rows,
+ cols)
+
+# XXX new function
+class List(Base):
+ ''' list the items specified by property using the standard index for
+ the class
+ '''
+ def __call__(self, property, **args):
+ propclass = self.properties[property]
+ if not propclass.isMultilinkType:
+ return '[List: not a Multilink]'
+ fp = StringIO.StringIO()
+ args['show_display_form'] = 0
+ value = self.cl.get(self.nodeid, property)
+ # 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 = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
+ '<tr class="list-header">',
+ '<td><span class="list-item"><strong>Date</strong></span></td>',
+ '<td><span class="list-item"><strong>User</strong></span></td>',
+ '<td><span class="list-item"><strong>Action</strong></span></td>',
+ '<td><span class="list-item"><strong>Args</strong></span></td>']
+
+ for id, date, user, action, args in self.cl.history(self.nodeid):
+ l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
+ date, user, action, args))
+ l.append('</table>')
+ return '\n'.join(l)
+
+# XXX new function
+class Submit(Base):
+ ''' add a submit button for the item
+ '''
+ def __call__(self):
+ if self.nodeid:
+ return '<input type="submit" value="Submit Changes">'
+ elif self.form is not None:
+ return '<input type="submit" value="Submit New Entry">'
+ else:
+ return '[Submit: not called from item]'
+
+
+#
+# INDEX TEMPLATES
+#
+class IndexTemplateReplace:
+ def __init__(self, globals, locals, props):
+ self.globals = globals
+ self.locals = locals
+ self.props = props
+
+ def go(self, text, replace=re.compile(
+ r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
+ r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)):
+ return replace.sub(self, text)
+
+ def __call__(self, m, filter=None, columns=None, sort=None, group=None):
+ if m.group('name'):
+ if m.group('name') in self.props:
+ text = m.group('text')
+ replace = IndexTemplateReplace(self.globals, {}, self.props)
+ return replace.go(m.group('text'))
+ else:
+ return ''
+ if m.group('display'):
+ command = m.group('command')
+ return eval(command, self.globals, self.locals)
+ print '*** unhandled match', m.groupdict()
+
+def sortby(sort_name, columns, filter, sort, group, filterspec):
+ l = []
+ w = l.append
+ for k, v in filterspec.items():
+ k = urllib.quote(k)
+ if type(v) == type([]):
+ w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
+ else:
+ w('%s=%s'%(k, urllib.quote(v)))
+ if columns:
+ w(':columns=%s'%','.join(map(urllib.quote, columns)))
+ if filter:
+ w(':filter=%s'%','.join(map(urllib.quote, filter)))
+ if group:
+ w(':group=%s'%','.join(map(urllib.quote, group)))
+ m = []
+ s_dir = ''
+ for name in sort:
+ dir = name[0]
+ if dir == '-':
+ dir = ''
+ else:
+ name = name[1:]
+ if sort_name == name:
+ if dir == '':
+ s_dir = '-'
+ elif dir == '-':
+ s_dir = ''
+ else:
+ m.append(dir+urllib.quote(name))
+ m.insert(0, s_dir+urllib.quote(sort_name))
+ # so things don't get completely out of hand, limit the sort to two columns
+ w(':sort=%s'%','.join(m[:2]))
+ return '&'.join(l)
+
+def index(client, templates, db, classname, filterspec={}, filter=[],
+ columns=[], sort=[], group=[], show_display_form=1, nodeids=None,
+ col_re=re.compile(r'<property\s+name="([^>]+)">')):
+ globals = {
+ 'plain': Plain(db, 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('<form>')
+ w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+ w('<tr class="location-bar">')
+ w(' <th align="left" colspan="2">Filter specification...</th>')
+ w('</tr>')
+ replace = IndexTemplateReplace(globals, locals(), filter)
+ w(replace.go(template))
+ if columns:
+ w('<input type="hidden" name=":columns" value="%s">'%','.join(columns))
+ if filter:
+ w('<input type="hidden" name=":filter" value="%s">'%','.join(filter))
+ if sort:
+ w('<input type="hidden" name=":sort" value="%s">'%','.join(sort))
+ if group:
+ w('<input type="hidden" name=":group" value="%s">'%','.join(group))
+ for k, v in filterspec.items():
+ if type(v) == type([]): v = ','.join(v)
+ w('<input type="hidden" name="%s" value="%s">'%(k, v))
+ w('<tr class="location-bar"><td width="1%%"> </td>')
+ w('<td><input type="submit" value="Redisplay"></td></tr>')
+ w('</table>')
+ w('</form>')
+
+ # XXX deviate from spec here ...
+ # load the index section template and figure the default columns from it
+ template = open(os.path.join(templates, classname+'.index')).read()
+ all_columns = col_re.findall(template)
+ if not columns:
+ columns = []
+ for name in all_columns:
+ columns.append(name)
+ else:
+ # re-sort columns to be the same order as all_columns
+ l = []
+ for name in all_columns:
+ if name in columns:
+ l.append(name)
+ columns = l
+
+ # now display the index section
+ w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+ w('<tr class="list-header">')
+ for name in columns:
+ cname = name.capitalize()
+ if show_display_form:
+ anchor = "%s?%s"%(classname, sortby(name, columns, filter,
+ sort, group, filterspec))
+ w('<td><span class="list-item"><a href="%s">%s</a></span></td>'%(
+ anchor, cname))
+ else:
+ w('<td><span class="list-item">%s</span></td>'%cname)
+ w('</tr>')
+
+ # this stuff is used for group headings - optimise the group names
+ old_group = None
+ group_names = []
+ if group:
+ for name in group:
+ if name[0] == '-': group_names.append(name[1:])
+ else: group_names.append(name)
+
+ # now actually loop through all the nodes we get from the filter and
+ # apply the template
+ if nodeids is None:
+ nodeids = cl.filter(filterspec, sort, group)
+ for nodeid in nodeids:
+ # check for a group heading
+ if group_names:
+ this_group = [cl.get(nodeid, name) for name in group_names]
+ if this_group != old_group:
+ l = []
+ for name in group_names:
+ prop = properties[name]
+ if prop.isLinkType:
+ group_cl = db.classes[prop.classname]
+ key = group_cl.getkey()
+ value = cl.get(nodeid, name)
+ if value is None:
+ l.append('[unselected %s]'%prop.classname)
+ else:
+ l.append(group_cl.get(cl.get(nodeid, name), key))
+ elif prop.isMultilinkType:
+ group_cl = db.classes[prop.classname]
+ key = group_cl.getkey()
+ for value in cl.get(nodeid, name):
+ l.append(group_cl.get(value, key))
+ else:
+ value = cl.get(nodeid, name)
+ if value is None:
+ value = '[empty %s]'%name
+ l.append(value)
+ w('<tr class="list-header">'
+ '<td align=left colspan=%s><strong>%s</strong></td></tr>'%(
+ len(columns), ', '.join(l)))
+ old_group = this_group
+
+ # display this node's row
+ for value in globals.values():
+ if hasattr(value, 'nodeid'):
+ value.nodeid = nodeid
+ replace = IndexTemplateReplace(globals, locals(), columns)
+ w(replace.go(template))
+
+ w('</table>')
+
+ if not show_display_form:
+ return
+
+ # now add in the filter/columns/group/etc config table form
+ w('<p><form>')
+ w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+ for k,v in filterspec.items():
+ if type(v) == type([]): v = ','.join(v)
+ w('<input type="hidden" name="%s" value="%s">'%(k, v))
+ if sort:
+ w('<input type="hidden" name=":sort" value="%s">'%','.join(sort))
+ names = []
+ for name in cl.getprops().keys():
+ if name in all_filters or name in all_columns:
+ names.append(name)
+ w('<tr class="location-bar">')
+ w('<th align="left" colspan=%s>View customisation...</th></tr>'%
+ (len(names)+1))
+ w('<tr class="location-bar"><th> </th>')
+ for name in names:
+ w('<th>%s</th>'%name.capitalize())
+ w('</tr>')
+
+ # filter
+ if all_filters:
+ w('<tr><th width="1%" align=right class="location-bar">Filters</th>')
+ for name in names:
+ if name not in all_filters:
+ w('<td> </td>')
+ continue
+ if name in filter: checked=' checked'
+ else: checked=''
+ w('<td align=middle>')
+ w('<input type="checkbox" name=":filter" value="%s" %s></td>'%(name,
+ checked))
+ w('</tr>')
+
+ # columns
+ if all_columns:
+ w('<tr><th width="1%" align=right class="location-bar">Columns</th>')
+ for name in names:
+ if name not in all_columns:
+ w('<td> </td>')
+ continue
+ if name in columns: checked=' checked'
+ else: checked=''
+ w('<td align=middle>')
+ w('<input type="checkbox" name=":columns" value="%s" %s></td>'%(
+ name, checked))
+ w('</tr>')
+
+ # group
+ w('<tr><th width="1%" align=right class="location-bar">Grouping</th>')
+ for name in names:
+ prop = properties[name]
+ if name not in all_columns:
+ w('<td> </td>')
+ continue
+ if name in group: checked=' checked'
+ else: checked=''
+ w('<td align=middle>')
+ w('<input type="checkbox" name=":group" value="%s" %s></td>'%(
+ name, checked))
+ w('</tr>')
+
+ w('<tr class="location-bar"><td width="1%"> </td>')
+ w('<td colspan="%s">'%len(names))
+ w('<input type="submit" value="Redisplay"></td></tr>')
+ w('</table>')
+ w('</form>')
+
+
+#
+# ITEM TEMPLATES
+#
+class ItemTemplateReplace:
+ def __init__(self, globals, locals, cl, nodeid):
+ self.globals = globals
+ self.locals = locals
+ self.cl = cl
+ self.nodeid = nodeid
+
+ def go(self, text, replace=re.compile(
+ r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
+ r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)):
+ return replace.sub(self, text)
+
+ def __call__(self, m, filter=None, columns=None, sort=None, group=None):
+ if m.group('name'):
+ if self.nodeid and self.cl.get(self.nodeid, m.group('name')):
+ replace = ItemTemplateReplace(self.globals, {}, self.cl,
+ self.nodeid)
+ return replace.go(m.group('text'))
+ else:
+ return ''
+ if m.group('display'):
+ command = m.group('command')
+ return eval(command, self.globals, self.locals)
+ print '*** unhandled match', m.groupdict()
+
+def item(client, templates, db, classname, nodeid, replace=re.compile(
+ r'((?P<prop><property\s+name="(?P<propname>[^>]+)">)|'
+ r'(?P<endprop></property>)|'
+ r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I)):
+
+ globals = {
+ 'plain': Plain(db, 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('<form action="%s%s">'%(classname, nodeid))
+ s = open(os.path.join(templates, classname+'.item')).read()
+ replace = ItemTemplateReplace(globals, locals(), cl, nodeid)
+ w(replace.go(s))
+ w('</form>')
+
+
+def newitem(client, templates, db, classname, form, replace=re.compile(
+ r'((?P<prop><property\s+name="(?P<propname>[^>]+)">)|'
+ r'(?P<endprop></property>)|'
+ r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I)):
+ globals = {
+ 'plain': Plain(db, 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('<form action="new%s">'%classname)
+ replace = ItemTemplateReplace(globals, locals(), None, None)
+ w(replace.go(s))
+ w('</form>')
+
+#
+# $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
--- /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
--- /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
--- /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
--- /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<classname>[^\d]+)(?P<nodeid>\d+)?\])'
+ r'(?P<title>[^\[]+)(\[(?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
--- /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
--- /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.
+#
+#
+
+