Code

More Grande Splite
authorrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Sun, 22 Jul 2001 12:01:27 +0000 (12:01 +0000)
committerrichard <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]
roundup/cgi_client.py [new file with mode: 0644]
roundup/cgitb.py [new file with mode: 0644]
roundup/date.py [new file with mode: 0644]
roundup/htmltemplate.py [new file with mode: 0644]
roundup/hyper_bsddb.py [new file with mode: 0644]
roundup/hyperdb.py [new file with mode: 0644]
roundup/init.py [new file with mode: 0644]
roundup/mailgw.py [new file with mode: 0644]
roundup/roundupdb.py [new file with mode: 0644]
templates/__init__.py [new file with mode: 0644]

diff --git a/roundup/__init__.py b/roundup/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/roundup/cgi_client.py b/roundup/cgi_client.py
new file mode 100644 (file)
index 0000000..8af92a2
--- /dev/null
@@ -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
new file mode 100644 (file)
index 0000000..60ef528
--- /dev/null
@@ -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>&nbsp;</tt>' % ('&nbsp;' * 5)
+    traceback = []
+    for frame, file, lnum, func, lines, index in inspect.trace(context):
+        if file is None:
+            link = '&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;'
+        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&nbsp;= %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 = '&nbsp;' * (5-len(str(i))) + str(i)
+            number = '<small><font color="#909090">%s</font></small>' % number
+            line = '<tt>%s&nbsp;%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&nbsp;= %s' % (indent, name, value))
+
+    return head + string.join(attribs) + string.join(traceback) + '<p>&nbsp;</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
new file mode 100644 (file)
index 0000000..e516de2
--- /dev/null
@@ -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
new file mode 100644 (file)
index 0000000..dd17ac2
--- /dev/null
@@ -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 = '&quot;'.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%%">&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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%">&nbsp;</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
new file mode 100644 (file)
index 0000000..996319e
--- /dev/null
@@ -0,0 +1,169 @@
+#$Id: hyper_bsddb.py,v 1.1 2001-07-22 11:58:35 richard Exp $
+
+import bsddb, os, cPickle
+import hyperdb, date
+
+#
+# Now the database
+#
+class Database(hyperdb.Database):
+    """A database for storing records containing flexible data types."""
+
+    def __init__(self, storagelocator, journaltag=None):
+        """Open a hyperdatabase given a specifier to some storage.
+
+        The meaning of 'storagelocator' depends on the particular
+        implementation of the hyperdatabase.  It could be a file name,
+        a directory path, a socket descriptor for a connection to a
+        database over the network, etc.
+
+        The 'journaltag' is a token that will be attached to the journal
+        entries for any edits done on the database.  If 'journaltag' is
+        None, the database is opened in read-only mode: the Class.create(),
+        Class.set(), and Class.retire() methods are disabled.
+        """
+        self.dir, self.journaltag = storagelocator, journaltag
+        self.classes = {}
+
+    #
+    # Classes
+    #
+    def __getattr__(self, classname):
+        """A convenient way of calling self.getclass(classname)."""
+        return self.classes[classname]
+
+    def addclass(self, cl):
+        cn = cl.classname
+        if self.classes.has_key(cn):
+            raise ValueError, cn
+        self.classes[cn] = cl
+
+    def getclasses(self):
+        """Return a list of the names of all existing classes."""
+        l = self.classes.keys()
+        l.sort()
+        return l
+
+    def getclass(self, classname):
+        """Get the Class object representing a particular class.
+
+        If 'classname' is not a valid class name, a KeyError is raised.
+        """
+        return self.classes[classname]
+
+    #
+    # Class DBs
+    #
+    def clear(self):
+        for cn in self.classes.keys():
+            db = os.path.join(self.dir, 'nodes.%s'%cn)
+            bsddb.btopen(db, 'n')
+            db = os.path.join(self.dir, 'journals.%s'%cn)
+            bsddb.btopen(db, 'n')
+
+    def getclassdb(self, classname, mode='r'):
+        ''' grab a connection to the class db that will be used for
+            multiple actions
+        '''
+        path = os.path.join(os.getcwd(), self.dir, 'nodes.%s'%classname)
+        return bsddb.btopen(path, mode)
+
+    def addnode(self, classname, nodeid, node):
+        ''' add the specified node to its class's db
+        '''
+        db = self.getclassdb(classname, 'c')
+        db[nodeid] = cPickle.dumps(node, 1)
+        db.close()
+    setnode = addnode
+
+    def getnode(self, classname, nodeid, cldb=None):
+        ''' add the specified node to its class's db
+        '''
+        db = cldb or self.getclassdb(classname)
+        if not db.has_key(nodeid):
+            raise IndexError, nodeid
+        res = cPickle.loads(db[nodeid])
+        if not cldb: db.close()
+        return res
+
+    def hasnode(self, classname, nodeid, cldb=None):
+        ''' add the specified node to its class's db
+        '''
+        db = cldb or self.getclassdb(classname)
+        res = db.has_key(nodeid)
+        if not cldb: db.close()
+        return res
+
+    def countnodes(self, classname, cldb=None):
+        db = cldb or self.getclassdb(classname)
+        return len(db.keys())
+        if not cldb: db.close()
+        return res
+
+    def getnodeids(self, classname, cldb=None):
+        db = cldb or self.getclassdb(classname)
+        res = db.keys()
+        if not cldb: db.close()
+        return res
+
+    #
+    # Journal
+    #
+    def addjournal(self, classname, nodeid, action, params):
+        ''' Journal the Action
+        'action' may be:
+
+            'create' or 'set' -- 'params' is a dictionary of property values
+            'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
+            'retire' -- 'params' is None
+        '''
+        entry = (nodeid, date.Date(), self.journaltag, action, params)
+        db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'c')
+        if db.has_key(nodeid):
+            s = db[nodeid]
+            l = cPickle.loads(db[nodeid])
+            l.append(entry)
+        else:
+            l = [entry]
+        db[nodeid] = cPickle.dumps(l)
+        db.close()
+
+    def getjournal(self, classname, nodeid):
+        ''' get the journal for id
+        '''
+        db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'r')
+        res = cPickle.loads(db[nodeid])
+        db.close()
+        return res
+
+    def close(self):
+        ''' Close the Database - we must release the circular refs so that
+            we can be del'ed and the underlying bsddb connections closed
+            cleanly.
+        '''
+        self.classes = None
+
+
+    #
+    # Basic transaction support
+    #
+    # TODO: well, write these methods (and then use them in other code)
+    def register_action(self):
+        ''' Register an action to the transaction undo log
+        '''
+
+    def commit(self):
+        ''' Commit the current transaction, start a new one
+        '''
+
+    def rollback(self):
+        ''' Reverse all actions from the current transaction
+        '''
+
+#
+#$Log: not supported by cvs2svn $
+#Revision 1.1  2001/07/20 07:35:55  richard
+#largish changes as a start of splitting off bits and pieces to allow more
+#flexible installation / database back-ends
+#
+
diff --git a/roundup/hyperdb.py b/roundup/hyperdb.py
new file mode 100644 (file)
index 0000000..5dfb416
--- /dev/null
@@ -0,0 +1,747 @@
+# $Id: hyperdb.py,v 1.1 2001-07-22 11:58:35 richard Exp $
+
+# standard python modules
+import cPickle, re, string
+
+# roundup modules
+import date
+
+
+#
+# Types
+#
+class BaseType:
+    isStringType = 0
+    isDateType = 0
+    isIntervalType = 0
+    isLinkType = 0
+    isMultilinkType = 0
+
+class String(BaseType):
+    def __init__(self):
+        """An object designating a String property."""
+        pass
+    def __repr__(self):
+        return '<%s>'%self.__class__
+    isStringType = 1
+
+class Date(BaseType, String):
+    isDateType = 1
+
+class Interval(BaseType, String):
+    isIntervalType = 1
+
+class Link(BaseType):
+    def __init__(self, classname):
+        """An object designating a Link property that links to
+        nodes in a specified class."""
+        self.classname = classname
+    def __repr__(self):
+        return '<%s to "%s">'%(self.__class__, self.classname)
+    isLinkType = 1
+
+class Multilink(BaseType, Link):
+    """An object designating a Multilink property that links
+       to nodes in a specified class.
+    """
+    isMultilinkType = 1
+
+class DatabaseError(ValueError):
+    pass
+
+
+#
+# the base Database class
+#
+class Database:
+    # flag to set on retired entries
+    RETIRED_FLAG = '__hyperdb_retired'
+
+
+#
+# The base Class class
+#
+class Class:
+    """The handle to a particular class of nodes in a hyperdatabase."""
+
+    def __init__(self, db, classname, **properties):
+        """Create a new class with a given name and property specification.
+
+        'classname' must not collide with the name of an existing class,
+        or a ValueError is raised.  The keyword arguments in 'properties'
+        must map names to property objects, or a TypeError is raised.
+        """
+        self.classname = classname
+        self.properties = properties
+        self.db = db
+        self.key = ''
+
+        # do the db-related init stuff
+        db.addclass(self)
+
+    # Editing nodes:
+
+    def create(self, **propvalues):
+        """Create a new node of this class and return its id.
+
+        The keyword arguments in 'propvalues' map property names to values.
+
+        The values of arguments must be acceptable for the types of their
+        corresponding properties or a TypeError is raised.
+        
+        If this class has a key property, it must be present and its value
+        must not collide with other key strings or a ValueError is raised.
+        
+        Any other properties on this class that are missing from the
+        'propvalues' dictionary are set to None.
+        
+        If an id in a link or multilink property does not refer to a valid
+        node, an IndexError is raised.
+        """
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        newid = str(self.count() + 1)
+
+        # validate propvalues
+        num_re = re.compile('^\d+$')
+        for key, value in propvalues.items():
+            if key == self.key:
+                try:
+                    self.lookup(value)
+                except KeyError:
+                    pass
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            prop = self.properties[key]
+
+            if prop.isLinkType:
+                value = str(value)
+                link_class = self.properties[key].classname
+                if not num_re.match(value):
+                    try:
+                        value = self.db.classes[link_class].lookup(value)
+                    except:
+                        raise ValueError, 'new property "%s": %s not a %s'%(
+                            key, value, self.properties[key].classname)
+                propvalues[key] = value
+                if not self.db.hasnode(link_class, value):
+                    raise ValueError, '%s has no node %s'%(link_class, value)
+
+                # register the link with the newly linked node
+                self.db.addjournal(link_class, value, 'link',
+                    (self.classname, newid, key))
+
+            elif prop.isMultilinkType:
+                if type(value) != type([]):
+                    raise TypeError, 'new property "%s" not a list of ids'%key
+                link_class = self.properties[key].classname
+                l = []
+                for entry in map(str, value):
+                    if not num_re.match(entry):
+                        try:
+                            entry = self.db.classes[link_class].lookup(entry)
+                        except:
+                            raise ValueError, 'new property "%s": %s not a %s'%(
+                                key, entry, self.properties[key].classname)
+                    l.append(entry)
+                value = l
+                propvalues[key] = value
+
+                # handle additions
+                for id in value:
+                    if not self.db.hasnode(link_class, id):
+                        raise ValueError, '%s has no node %s'%(link_class, id)
+                    # register the link with the newly linked node
+                    self.db.addjournal(link_class, id, 'link',
+                        (self.classname, newid, key))
+
+            elif prop.isStringType:
+                if type(value) != type(''):
+                    raise TypeError, 'new property "%s" not a string'%key
+
+            elif prop.isDateType:
+                if not hasattr(value, 'isDate'):
+                    raise TypeError, 'new property "%s" not a Date'% key
+
+            elif prop.isIntervalType:
+                if not hasattr(value, 'isInterval'):
+                    raise TypeError, 'new property "%s" not an Interval'% key
+
+        for key,prop in self.properties.items():
+            if propvalues.has_key(str(key)):
+                continue
+            if prop.isMultilinkType:
+                propvalues[key] = []
+            else:
+                propvalues[key] = None
+
+        # done
+        self.db.addnode(self.classname, newid, propvalues)
+        self.db.addjournal(self.classname, newid, 'create', propvalues)
+        return newid
+
+    def get(self, nodeid, propname):
+        """Get the value of a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.  'propname' must be the name of a property
+        of this class or a KeyError is raised.
+        """
+        d = self.db.getnode(self.classname, str(nodeid))
+        return d[propname]
+
+    # XXX not in spec
+    def getnode(self, nodeid):
+        ''' Return a convenience wrapper for the node
+        '''
+        return Node(self, nodeid)
+
+    def set(self, nodeid, **propvalues):
+        """Modify a property on an existing node of this class.
+        
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        Each key in 'propvalues' must be the name of a property of this
+        class or a KeyError is raised.
+
+        All values in 'propvalues' must be acceptable types for their
+        corresponding properties or a TypeError is raised.
+
+        If the value of the key property is set, it must not collide with
+        other key strings or a ValueError is raised.
+
+        If the value of a Link or Multilink property contains an invalid
+        node id, a ValueError is raised.
+        """
+        if not propvalues:
+            return
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        nodeid = str(nodeid)
+        node = self.db.getnode(self.classname, nodeid)
+        if node.has_key(self.db.RETIRED_FLAG):
+            raise IndexError
+        num_re = re.compile('^\d+$')
+        for key, value in propvalues.items():
+            if not node.has_key(key):
+                raise KeyError, key
+
+            if key == self.key:
+                try:
+                    self.lookup(value)
+                except KeyError:
+                    pass
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            prop = self.properties[key]
+
+            if prop.isLinkType:
+                value = str(value)
+                link_class = self.properties[key].classname
+                if not num_re.match(value):
+                    try:
+                        value = self.db.classes[link_class].lookup(value)
+                    except:
+                        raise ValueError, 'new property "%s": %s not a %s'%(
+                            key, value, self.properties[key].classname)
+
+                if not self.db.hasnode(link_class, value):
+                    raise ValueError, '%s has no node %s'%(link_class, value)
+
+                # register the unlink with the old linked node
+                if node[key] is not None:
+                    self.db.addjournal(link_class, node[key], 'unlink',
+                        (self.classname, nodeid, key))
+
+                # register the link with the newly linked node
+                if value is not None:
+                    self.db.addjournal(link_class, value, 'link',
+                        (self.classname, nodeid, key))
+
+            elif prop.isMultilinkType:
+                if type(value) != type([]):
+                    raise TypeError, 'new property "%s" not a list of ids'%key
+                link_class = self.properties[key].classname
+                l = []
+                for entry in map(str, value):
+                    if not num_re.match(entry):
+                        try:
+                            entry = self.db.classes[link_class].lookup(entry)
+                        except:
+                            raise ValueError, 'new property "%s": %s not a %s'%(
+                                key, entry, self.properties[key].classname)
+                    l.append(entry)
+                value = l
+                propvalues[key] = value
+
+                #handle removals
+                l = node[key]
+                for id in l[:]:
+                    if id in value:
+                        continue
+                    # register the unlink with the old linked node
+                    self.db.addjournal(link_class, id, 'unlink',
+                        (self.classname, nodeid, key))
+                    l.remove(id)
+
+                # handle additions
+                for id in value:
+                    if not self.db.hasnode(link_class, id):
+                        raise ValueError, '%s has no node %s'%(link_class, id)
+                    if id in l:
+                        continue
+                    # register the link with the newly linked node
+                    self.db.addjournal(link_class, id, 'link',
+                        (self.classname, nodeid, key))
+                    l.append(id)
+
+            elif prop.isStringType:
+                if value is not None and type(value) != type(''):
+                    raise TypeError, 'new property "%s" not a string'%key
+
+            elif prop.isDateType:
+                if not hasattr(value, 'isDate'):
+                    raise TypeError, 'new property "%s" not a Date'% key
+
+            elif prop.isIntervalType:
+                if not hasattr(value, 'isInterval'):
+                    raise TypeError, 'new property "%s" not an Interval'% key
+
+            node[key] = value
+
+        self.db.setnode(self.classname, nodeid, node)
+        self.db.addjournal(self.classname, nodeid, 'set', propvalues)
+
+    def retire(self, nodeid):
+        """Retire a node.
+        
+        The properties on the node remain available from the get() method,
+        and the node's id is never reused.
+        
+        Retired nodes are not returned by the find(), list(), or lookup()
+        methods, and other nodes may reuse the values of their key properties.
+        """
+        nodeid = str(nodeid)
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        node = self.db.getnode(self.classname, nodeid)
+        node[self.db.RETIRED_FLAG] = 1
+        self.db.setnode(self.classname, nodeid, node)
+        self.db.addjournal(self.classname, nodeid, 'retired', None)
+
+    def history(self, nodeid):
+        """Retrieve the journal of edits on a particular node.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        The returned list contains tuples of the form
+
+            (date, tag, action, params)
+
+        'date' is a Timestamp object specifying the time of the change and
+        'tag' is the journaltag specified when the database was opened.
+        """
+        return self.db.getjournal(self.classname, nodeid)
+
+    # Locating nodes:
+
+    def setkey(self, propname):
+        """Select a String property of this class to be the key property.
+
+        'propname' must be the name of a String property of this class or
+        None, or a TypeError is raised.  The values of the key property on
+        all existing nodes must be unique or a ValueError is raised.
+        """
+        self.key = propname
+
+    def getkey(self):
+        """Return the name of the key property for this class or None."""
+        return self.key
+
+    # TODO: set up a separate index db file for this? profile?
+    def lookup(self, keyvalue):
+        """Locate a particular node by its key property and return its id.
+
+        If this class has no key property, a TypeError is raised.  If the
+        'keyvalue' matches one of the values for the key property among
+        the nodes in this class, the matching node's id is returned;
+        otherwise a KeyError is raised.
+        """
+        cldb = self.db.getclassdb(self.classname)
+        for nodeid in self.db.getnodeids(self.classname, cldb):
+            node = self.db.getnode(self.classname, nodeid, cldb)
+            if node.has_key(self.db.RETIRED_FLAG):
+                continue
+            if node[self.key] == keyvalue:
+                return nodeid
+        cldb.close()
+        raise KeyError, keyvalue
+
+    # XXX: change from spec - allows multiple props to match
+    def find(self, **propspec):
+        """Get the ids of nodes in this class which link to a given node.
+
+        'propspec' consists of keyword args propname=nodeid   
+          'propname' must be the name of a property in this class, or a
+            KeyError is raised.  That property must be a Link or Multilink
+            property, or a TypeError is raised.
+
+          'nodeid' must be the id of an existing node in the class linked
+            to by the given property, or an IndexError is raised.
+        """
+        propspec = propspec.items()
+        for propname, nodeid in propspec:
+            nodeid = str(nodeid)
+            # check the prop is OK
+            prop = self.properties[propname]
+            if not prop.isLinkType and not prop.isMultilinkType:
+                raise TypeError, "'%s' not a Link/Multilink property"%propname
+            if not self.db.hasnode(prop.classname, nodeid):
+                raise ValueError, '%s has no node %s'%(link_class, nodeid)
+
+        # ok, now do the find
+        cldb = self.db.getclassdb(self.classname)
+        l = []
+        for id in self.db.getnodeids(self.classname, cldb):
+            node = self.db.getnode(self.classname, id, cldb)
+            if node.has_key(self.db.RETIRED_FLAG):
+                continue
+            for propname, nodeid in propspec:
+                nodeid = str(nodeid)
+                property = node[propname]
+                if prop.isLinkType and nodeid == property:
+                    l.append(id)
+                elif prop.isMultilinkType and nodeid in property:
+                    l.append(id)
+        cldb.close()
+        return l
+
+    def stringFind(self, **requirements):
+        """Locate a particular node by matching a set of its String properties.
+
+        If the property is not a String property, a TypeError is raised.
+        
+        The return is a list of the id of all nodes that match.
+        """
+        for propname in requirements.keys():
+            prop = self.properties[propname]
+            if not prop.isStringType:
+                raise TypeError, "'%s' not a String property"%propname
+        l = []
+        cldb = self.db.getclassdb(self.classname)
+        for nodeid in self.db.getnodeids(self.classname, cldb):
+            node = self.db.getnode(self.classname, nodeid, cldb)
+            if node.has_key(self.db.RETIRED_FLAG):
+                continue
+            for key, value in requirements.items():
+                if node[key] != value:
+                    break
+            else:
+                l.append(nodeid)
+        cldb.close()
+        return l
+
+    def list(self):
+        """Return a list of the ids of the active nodes in this class."""
+        l = []
+        cn = self.classname
+        cldb = self.db.getclassdb(cn)
+        for nodeid in self.db.getnodeids(cn, cldb):
+            node = self.db.getnode(cn, nodeid, cldb)
+            if node.has_key(self.db.RETIRED_FLAG):
+                continue
+            l.append(nodeid)
+        l.sort()
+        cldb.close()
+        return l
+
+    # XXX not in spec
+    def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
+        ''' Return a list of the ids of the active nodes in this class that
+            match the 'filter' spec, sorted by the group spec and then the
+            sort spec
+        '''
+        cn = self.classname
+
+        # optimise filterspec
+        l = []
+        props = self.getprops()
+        for k, v in filterspec.items():
+            propclass = props[k]
+            if propclass.isLinkType:
+                if type(v) is not type([]):
+                    v = [v]
+                # replace key values with node ids
+                u = []
+                link_class =  self.db.classes[propclass.classname]
+                for entry in v:
+                    if not num_re.match(entry):
+                        try:
+                            entry = link_class.lookup(entry)
+                        except:
+                            raise ValueError, 'new property "%s": %s not a %s'%(
+                                k, entry, self.properties[k].classname)
+                    u.append(entry)
+
+                l.append((0, k, u))
+            elif propclass.isMultilinkType:
+                if type(v) is not type([]):
+                    v = [v]
+                # replace key values with node ids
+                u = []
+                link_class =  self.db.classes[propclass.classname]
+                for entry in v:
+                    if not num_re.match(entry):
+                        try:
+                            entry = link_class.lookup(entry)
+                        except:
+                            raise ValueError, 'new property "%s": %s not a %s'%(
+                                k, entry, self.properties[k].classname)
+                    u.append(entry)
+                l.append((1, k, u))
+            elif propclass.isStringType:
+                v = v[0]
+                if '*' in v or '?' in v:
+                    # simple glob searching
+                    v = v.replace('?', '.')
+                    v = v.replace('*', '.*?')
+                    v = re.compile(v)
+                    l.append((2, k, v))
+                elif v[0] == '^':
+                    # start-anchored
+                    if v[-1] == '$':
+                        # _and_ end-anchored
+                        l.append((6, k, v[1:-1]))
+                    l.append((3, k, v[1:]))
+                elif v[-1] == '$':
+                    # end-anchored
+                    l.append((4, k, v[:-1]))
+                else:
+                    # substring
+                    l.append((5, k, v))
+            else:
+                l.append((6, k, v))
+        filterspec = l
+
+        # now, find all the nodes that are active and pass filtering
+        l = []
+        cldb = self.db.getclassdb(cn)
+        for nodeid in self.db.getnodeids(cn, cldb):
+            node = self.db.getnode(cn, nodeid, cldb)
+            if node.has_key(self.db.RETIRED_FLAG):
+                continue
+            # apply filter
+            for t, k, v in filterspec:
+                if t == 0 and node[k] not in v:
+                    # link - if this node'd property doesn't appear in the
+                    # filterspec's nodeid list, skip it
+                    break
+                elif t == 1:
+                    # multilink - if any of the nodeids required by the
+                    # filterspec aren't in this node's property, then skip
+                    # it
+                    for value in v:
+                        if value not in node[k]:
+                            break
+                    else:
+                        continue
+                    break
+                elif t == 2 and not v.search(node[k]):
+                    # RE search
+                    break
+                elif t == 3 and node[k][:len(v)] != v:
+                    # start anchored
+                    break
+                elif t == 4 and node[k][-len(v):] != v:
+                    # end anchored
+                    break
+                elif t == 5 and node[k].find(v) == -1:
+                    # substring search
+                    break
+                elif t == 6 and node[k] != v:
+                    # straight value comparison for the other types
+                    break
+            else:
+                l.append((nodeid, node))
+        l.sort()
+        cldb.close()
+
+        # optimise sort
+        m = []
+        for entry in sort:
+            if entry[0] != '-':
+                m.append(('+', entry))
+            else:
+                m.append((entry[0], entry[1:]))
+        sort = m
+
+        # optimise group
+        m = []
+        for entry in group:
+            if entry[0] != '-':
+                m.append(('+', entry))
+            else:
+                m.append((entry[0], entry[1:]))
+        group = m
+
+        # now, sort the result
+        def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
+                db = self.db, cl=self):
+            a_id, an = a
+            b_id, bn = b
+            for list in group, sort:
+                for dir, prop in list:
+                    # handle the properties that might be "faked"
+                    if not an.has_key(prop):
+                        an[prop] = cl.get(a_id, prop)
+                    av = an[prop]
+                    if not bn.has_key(prop):
+                        bn[prop] = cl.get(b_id, prop)
+                    bv = bn[prop]
+
+                    # sorting is class-specific
+                    propclass = properties[prop]
+
+                    # String and Date values are sorted in the natural way
+                    if propclass.isStringType:
+                        # clean up the strings
+                        if av and av[0] in string.uppercase:
+                            av = an[prop] = av.lower()
+                        if bv and bv[0] in string.uppercase:
+                            bv = bn[prop] = bv.lower()
+                    if propclass.isStringType or propclass.isDateType:
+                        if dir == '+':
+                            r = cmp(av, bv)
+                            if r != 0: return r
+                        elif dir == '-':
+                            r = cmp(bv, av)
+                            if r != 0: return r
+
+                    # Link properties are sorted according to the value of
+                    # the "order" property on the linked nodes if it is
+                    # present; or otherwise on the key string of the linked
+                    # nodes; or finally on  the node ids.
+                    elif propclass.isLinkType:
+                        link = db.classes[propclass.classname]
+                        if link.getprops().has_key('order'):
+                            if dir == '+':
+                                r = cmp(link.get(av, 'order'),
+                                    link.get(bv, 'order'))
+                                if r != 0: return r
+                            elif dir == '-':
+                                r = cmp(link.get(bv, 'order'),
+                                    link.get(av, 'order'))
+                                if r != 0: return r
+                        elif link.getkey():
+                            key = link.getkey()
+                            if dir == '+':
+                                r = cmp(link.get(av, key), link.get(bv, key))
+                                if r != 0: return r
+                            elif dir == '-':
+                                r = cmp(link.get(bv, key), link.get(av, key))
+                                if r != 0: return r
+                        else:
+                            if dir == '+':
+                                r = cmp(av, bv)
+                                if r != 0: return r
+                            elif dir == '-':
+                                r = cmp(bv, av)
+                                if r != 0: return r
+
+                    # Multilink properties are sorted according to how many
+                    # links are present.
+                    elif propclass.isMultilinkType:
+                        if dir == '+':
+                            r = cmp(len(av), len(bv))
+                            if r != 0: return r
+                        elif dir == '-':
+                            r = cmp(len(bv), len(av))
+                            if r != 0: return r
+            return cmp(a[0], b[0])
+        l.sort(sortfun)
+        return [i[0] for i in l]
+
+    def count(self):
+        """Get the number of nodes in this class.
+
+        If the returned integer is 'numnodes', the ids of all the nodes
+        in this class run from 1 to numnodes, and numnodes+1 will be the
+        id of the next node to be created in this class.
+        """
+        return self.db.countnodes(self.classname)
+
+    # Manipulating properties:
+
+    def getprops(self):
+        """Return a dictionary mapping property names to property objects."""
+        return self.properties
+
+    def addprop(self, **properties):
+        """Add properties to this class.
+
+        The keyword arguments in 'properties' must map names to property
+        objects, or a TypeError is raised.  None of the keys in 'properties'
+        may collide with the names of existing properties, or a ValueError
+        is raised before any properties have been added.
+        """
+        for key in properties.keys():
+            if self.properties.has_key(key):
+                raise ValueError, key
+        self.properties.update(properties)
+
+
+# XXX not in spec
+class Node:
+    ''' A convenience wrapper for the given node
+    '''
+    def __init__(self, cl, nodeid):
+        self.__dict__['cl'] = cl
+        self.__dict__['nodeid'] = nodeid
+    def keys(self):
+        return self.cl.getprops().keys()
+    def has_key(self, name):
+        return self.cl.getprops().has_key(name)
+    def __getattr__(self, name):
+        if self.__dict__.has_key(name):
+            return self.__dict__['name']
+        try:
+            return self.cl.get(self.nodeid, name)
+        except KeyError, value:
+            raise AttributeError, str(value)
+    def __getitem__(self, name):
+        return self.cl.get(self.nodeid, name)
+    def __setattr__(self, name, value):
+        try:
+            return self.cl.set(self.nodeid, **{name: value})
+        except KeyError, value:
+            raise AttributeError, str(value)
+    def __setitem__(self, name, value):
+        self.cl.set(self.nodeid, **{name: value})
+    def history(self):
+        return self.cl.history(self.nodeid)
+    def retire(self):
+        return self.cl.retire(self.nodeid)
+
+
+def Choice(name, *options):
+    cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
+    for i in range(len(options)):
+        cl.create(name=option[i], order=i)
+    return hyperdb.Link(name)
+
+#
+# $Log: not supported by cvs2svn $
+# Revision 1.6  2001/07/20 08:20:24  richard
+# Fixed a bug in the filter - wrong variable names in the error message.
+# Recognised that the filter has an outstanding bug. Hrm. we need a bug tracker
+# for this project :)
+#
+# Revision 1.5  2001/07/20 07:35:55  richard
+# largish changes as a start of splitting off bits and pieces to allow more
+# flexible installation / database back-ends
+#
+
diff --git a/roundup/init.py b/roundup/init.py
new file mode 100644 (file)
index 0000000..4174afc
--- /dev/null
@@ -0,0 +1,45 @@
+import os, shutil, sys
+
+def copytree(src, dst, symlinks=0):
+    """Recursively copy a directory tree using copy2().
+
+    The destination directory os allowed to exist.
+
+    If the optional symlinks flag is true, symbolic links in the
+    source tree result in symbolic links in the destination tree; if
+    it is false, the contents of the files pointed to by symbolic
+    links are copied.
+
+    XXX copied from shutil.py in std lib
+
+    """
+    names = os.listdir(src)
+    try:
+        os.mkdir(dst)
+    except OSError, error:
+        if error.errno != 17: raise
+    for name in names:
+        srcname = os.path.join(src, name)
+        dstname = os.path.join(dst, name)
+        if symlinks and os.path.islink(srcname):
+            linkto = os.readlink(srcname)
+            os.symlink(linkto, dstname)
+        elif os.path.isdir(srcname):
+            copytree(srcname, dstname, symlinks)
+        else:
+            shutil.copy2(srcname, dstname)
+
+def init(instance, template, adminpw):
+    ''' initialise an instance using the named template
+    '''
+    # first, copy the template dir over
+    template_dir = os.path.split(__file__)[0]
+    template = os.path.join(template_dir, 'templates', template)
+    copytree(template, instance)
+
+    # now import the instance and call its init
+    path, instance = os.path.split(instance)
+    sys.path.insert(0, path)
+    instance = __import__(instance)
+    instance.init(adminpw)
+
diff --git a/roundup/mailgw.py b/roundup/mailgw.py
new file mode 100644 (file)
index 0000000..52e4057
--- /dev/null
@@ -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
new file mode 100644 (file)
index 0000000..b27493a
--- /dev/null
@@ -0,0 +1,249 @@
+# $Id: roundupdb.py,v 1.1 2001-07-22 11:58:35 richard Exp $
+
+import re, os, smtplib, socket
+
+import hyperdb, date
+
+def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
+    ''' Take a foo123 and return ('foo', 123)
+    '''
+    m = dre.match(designator)
+    return m.group(1), m.group(2)
+
+class Database:
+    def getuid(self):
+        """Return the id of the "user" node associated with the user
+        that owns this connection to the hyperdatabase."""
+        return self.user.lookup(self.journaltag)
+
+    def uidFromAddress(self, address):
+        ''' address is from the rfc822 module, and therefore is (name, addr)
+
+            user is created if they don't exist in the db already
+        '''
+        (realname, address) = address
+        users = self.user.stringFind(address=address)
+        if users: return users[0]
+        return self.user.create(username=address, address=address,
+            realname=realname)
+
+class Class(hyperdb.Class):
+    # Overridden methods:
+    def __init__(self, db, classname, **properties):
+        hyperdb.Class.__init__(self, db, classname, **properties)
+        self.auditors = {'create': [], 'set': [], 'retire': []}
+        self.reactors = {'create': [], 'set': [], 'retire': []}
+
+    def create(self, **propvalues):
+        """These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        """
+        if propvalues.has_key('creation') or propvalues.has_key('activity'):
+            raise KeyError, '"creation" and "activity" are reserved'
+        for audit in self.auditors['create']:
+            audit(self.db, self, None, propvalues)
+        nodeid = hyperdb.Class.create(self, **propvalues)
+        for react in self.reactors['create']:
+            react(self.db, self, nodeid, None)
+        return nodeid
+
+    def set(self, nodeid, **propvalues):
+        """These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        """
+        if propvalues.has_key('creation') or propvalues.has_key('activity'):
+            raise KeyError, '"creation" and "activity" are reserved'
+        for audit in self.auditors['set']:
+            audit(self.db, self, nodeid, propvalues)
+        oldvalues = self.db.getnode(self.classname, nodeid)
+        hyperdb.Class.set(self, nodeid, **propvalues)
+        for react in self.reactors['set']:
+            react(self.db, self, nodeid, oldvalues)
+
+    def retire(self, nodeid):
+        """These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        """
+        for audit in self.auditors['retire']:
+            audit(self.db, self, nodeid, None)
+        hyperdb.Class.retire(self, nodeid)
+        for react in self.reactors['retire']:
+            react(self.db, self, nodeid, None)
+
+    # New methods:
+
+    def audit(self, event, detector):
+        """Register a detector
+        """
+        self.auditors[event].append(detector)
+
+    def react(self, event, detector):
+        """Register a detector
+        """
+        self.reactors[event].append(detector)
+
+class FileClass(Class):
+    def create(self, **propvalues):
+        ''' snaffle the file propvalue and store in a file
+        '''
+        content = propvalues['content']
+        del propvalues['content']
+        newid = Class.create(self, **propvalues)
+        self.setcontent(self.classname, newid, content)
+        return newid
+
+    def filename(self, classname, nodeid):
+        # TODO: split into multiple files directories
+        return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
+
+    def setcontent(self, classname, nodeid, content):
+        ''' set the content file for this file
+        '''
+        open(self.filename(classname, nodeid), 'wb').write(content)
+
+    def getcontent(self, classname, nodeid):
+        ''' get the content file for this file
+        '''
+        return open(self.filename(classname, nodeid), 'rb').read()
+
+    def get(self, nodeid, propname):
+        ''' trap the content propname and get it from the file
+        '''
+        if propname == 'content':
+            return self.getcontent(self.classname, nodeid)
+        return Class.get(self, nodeid, propname)
+
+    def getprops(self):
+        ''' In addition to the actual properties on the node, these methods
+            provide the "content" property.
+        '''
+        d = Class.getprops(self).copy()
+        d['content'] = hyperdb.String()
+        return d
+
+# XXX deviation from spec - was called ItemClass
+class IssueClass(Class):
+    # Overridden methods:
+
+    def __init__(self, db, classname, **properties):
+        """The newly-created class automatically includes the "messages",
+        "files", "nosy", and "superseder" properties.  If the 'properties'
+        dictionary attempts to specify any of these properties or a
+        "creation" or "activity" property, a ValueError is raised."""
+        if not properties.has_key('title'):
+            properties['title'] = hyperdb.String()
+        if not properties.has_key('messages'):
+            properties['messages'] = hyperdb.Multilink("msg")
+        if not properties.has_key('files'):
+            properties['files'] = hyperdb.Multilink("file")
+        if not properties.has_key('nosy'):
+            properties['nosy'] = hyperdb.Multilink("user")
+        if not properties.has_key('superseder'):
+            properties['superseder'] = hyperdb.Multilink("issue")
+        if (properties.has_key('creation') or properties.has_key('activity')
+                or properties.has_key('creator')):
+            raise ValueError, '"creation", "activity" and "creator" are reserved'
+        Class.__init__(self, db, classname, **properties)
+
+    def get(self, nodeid, propname):
+        if propname == 'creation':
+            return self.db.getjournal(self.classname, nodeid)[0][1]
+        if propname == 'activity':
+            return self.db.getjournal(self.classname, nodeid)[-1][1]
+        if propname == 'creator':
+            name = self.db.getjournal(self.classname, nodeid)[0][2]
+            return self.db.user.lookup(name)
+        return Class.get(self, nodeid, propname)
+
+    def getprops(self):
+        """In addition to the actual properties on the node, these
+        methods provide the "creation" and "activity" properties."""
+        d = Class.getprops(self).copy()
+        d['creation'] = hyperdb.Date()
+        d['activity'] = hyperdb.Date()
+        d['creator'] = hyperdb.Link("user")
+        return d
+
+    # New methods:
+
+    def addmessage(self, nodeid, summary, text):
+        """Add a message to an issue's mail spool.
+
+        A new "msg" node is constructed using the current date, the user that
+        owns the database connection as the author, and the specified summary
+        text.
+
+        The "files" and "recipients" fields are left empty.
+
+        The given text is saved as the body of the message and the node is
+        appended to the "messages" field of the specified issue.
+        """
+
+    def sendmessage(self, nodeid, msgid):
+        """Send a message to the members of an issue's nosy list.
+
+        The message is sent only to users on the nosy list who are not
+        already on the "recipients" list for the message.
+        
+        These users are then added to the message's "recipients" list.
+        """
+        # figure the recipient ids
+        recipients = self.db.msg.get(msgid, 'recipients')
+        r = {}
+        for recipid in recipients:
+            r[recipid] = 1
+        authid = self.db.msg.get(msgid, 'author')
+        r[authid] = 1
+
+        # now figure the nosy people who weren't recipients
+        sendto = []
+        nosy = self.get(nodeid, 'nosy')
+        for nosyid in nosy:
+            if not r.has_key(nosyid):
+                sendto.append(nosyid)
+                recipients.append(nosyid)
+
+        if sendto:
+            # update the message's recipients list
+            self.db.msg.set(msgid, recipients=recipients)
+
+            # send an email to the people who missed out
+            sendto = [self.db.user.get(i, 'address') for i in recipients]
+            cn = self.classname
+            title = self.get(nodeid, 'title') or '%s message copy'%cn
+            m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
+            m.append('To: %s'%', '.join(sendto))
+            m.append('Reply-To: %s'%self.ISSUE_TRACKER_EMAIL)
+            m.append('')
+            m.append(self.db.msg.get(msgid, 'content'))
+            # TODO attachments
+            try:
+                smtp = smtplib.SMTP(self.MAILHOST)
+                smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
+            except socket.error, value:
+                return "Couldn't send confirmation email: mailhost %s"%value
+            except smtplib.SMTPException, value:
+                return "Couldn't send confirmation email: %s"%value
+
+#
+# $Log: not supported by cvs2svn $
+# Revision 1.6  2001/07/20 07:35:55  richard
+# largish changes as a start of splitting off bits and pieces to allow more
+# flexible installation / database back-ends
+#
+# Revision 1.5  2001/07/20 00:22:50  richard
+# Priority list changes - removed the redundant TODO and added support. See
+# roundup-devel for details.
+#
+# Revision 1.4  2001/07/19 06:27:07  anthonybaxter
+# fixing (manually) the (dollarsign)Log(dollarsign) entries caused by
+# my using the magic (dollarsign)Id(dollarsign) and (dollarsign)Log(dollarsign)
+# strings in a commit message. I'm a twonk.
+#
+# Also broke the help string in two.
+#
+# Revision 1.3  2001/07/19 05:52:22  anthonybaxter
+# Added CVS keywords Id and Log to all python files.
+#
+#
+
diff --git a/templates/__init__.py b/templates/__init__.py
new file mode 100644 (file)
index 0000000..e04751e
--- /dev/null
@@ -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.
+#
+#
+
+