Code

New CGI interface support
authorrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Fri, 30 Aug 2002 08:28:44 +0000 (08:28 +0000)
committerrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Fri, 30 Aug 2002 08:28:44 +0000 (08:28 +0000)
git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@1002 57a73879-2fb5-44c3-a270-3262357dd7e2

roundup/cgi/Acquisition.py [new file with mode: 0644]
roundup/cgi/ComputedAttribute.py [new file with mode: 0644]
roundup/cgi/ExtensionClass.py [new file with mode: 0644]
roundup/cgi/MultiMapping.py [new file with mode: 0644]
roundup/cgi/__init__.py [new file with mode: 0644]
roundup/cgi/cgitb.py [new file with mode: 0644]
roundup/cgi/client.py [new file with mode: 0644]
roundup/cgi/templating.py [new file with mode: 0644]
roundup/cgi/zLOG.py [new file with mode: 0644]

diff --git a/roundup/cgi/Acquisition.py b/roundup/cgi/Acquisition.py
new file mode 100644 (file)
index 0000000..bb5f227
--- /dev/null
@@ -0,0 +1,8 @@
+class Explicit:
+    pass
+
+def aq_base(obj):
+    return obj
+aq_inner = aq_base
+def aq_parent(obj):
+    return None
diff --git a/roundup/cgi/ComputedAttribute.py b/roundup/cgi/ComputedAttribute.py
new file mode 100644 (file)
index 0000000..7117fb4
--- /dev/null
@@ -0,0 +1,11 @@
+class ComputedAttribute:
+    def __init__(self, callable, level):
+        self.callable = callable
+        self.level = level
+    def __of__(self, *args):
+        if self.level > 0:
+            return self.callable
+        if isinstance(self.callable, type('')):
+            return getattr(args[0], self.callable)
+        return self.callable(*args)
+
diff --git a/roundup/cgi/ExtensionClass.py b/roundup/cgi/ExtensionClass.py
new file mode 100644 (file)
index 0000000..764e53e
--- /dev/null
@@ -0,0 +1,2 @@
+class Base:
+    pass
diff --git a/roundup/cgi/MultiMapping.py b/roundup/cgi/MultiMapping.py
new file mode 100644 (file)
index 0000000..b528288
--- /dev/null
@@ -0,0 +1,25 @@
+import operator
+
+class MultiMapping:
+    def __init__(self, *stores):
+        self.stores = list(stores)
+    def __getitem__(self, key):
+        for store in self.stores:
+            if store.has_key(key):
+                return store[key]
+        raise KeyError, key
+    _marker = []
+    def get(self, key, default=_marker):
+        for store in self.stores:
+            if store.has_key(key):
+                return store[key]
+        if default is self._marker:
+            raise KeyError, key
+        return default
+    def __len__(self):
+        return reduce(operator.add, [len(x) for x in stores], 0)
+    def push(self, store):
+        self.stores.append(store)
+    def pop(self):
+        return self.stores.pop()
+
diff --git a/roundup/cgi/__init__.py b/roundup/cgi/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/roundup/cgi/cgitb.py b/roundup/cgi/cgitb.py
new file mode 100644 (file)
index 0000000..21f1942
--- /dev/null
@@ -0,0 +1,161 @@
+#
+# This module was written by Ka-Ping Yee, <ping@lfw.org>.
+# 
+# $Id: cgitb.py,v 1.1 2002-08-30 08:28:44 richard Exp $
+
+__doc__ = """
+Extended CGI traceback handler by Ka-Ping Yee, <ping@lfw.org>.
+"""
+
+import sys, os, types, string, keyword, linecache, tokenize, inspect, pydoc
+
+from roundup.i18n import _
+
+def breaker():
+    return ('<body bgcolor="white">' +
+            '<font color="white" 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>'%(etype, evalue),
+        '#ffffff', '#777777', 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="#dddddd" cellspacing=0 cellpadding=2 border=0>
+<tr><td>%s %s</td></tr></table>''' % (link, call)
+
+        if index is None or 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="#white" 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.10  2002/01/16 04:49:45  richard
+# Handle a special case that the CGI interface tickles. I need to check if
+# this needs fixing in python's core.
+#
+# Revision 1.9  2002/01/08 11:56:24  richard
+# missed an import _
+#
+# Revision 1.8  2002/01/05 02:22:32  richard
+# i18n'ification
+#
+# Revision 1.7  2001/11/22 15:46:42  jhermann
+# Added module docstrings to all modules.
+#
+# Revision 1.6  2001/09/29 13:27:00  richard
+# CGI interfaces now spit up a top-level index of all the instances they can
+# serve.
+#
+# Revision 1.5  2001/08/07 00:24:42  richard
+# stupid typo
+#
+# Revision 1.4  2001/08/07 00:15:51  richard
+# Added the copyright/license notice to (nearly) all files at request of
+# Bizar Software.
+#
+# Revision 1.3  2001/07/29 07:01:39  richard
+# Added vim command to all source so that we don't get no steenkin' tabs :)
+#
+# Revision 1.2  2001/07/22 12:09:32  richard
+# Final commit of Grande Splite
+#
+# Revision 1.1  2001/07/22 11:58:35  richard
+# More Grande Splite
+#
+#
+# vim: set filetype=python ts=4 sw=4 et si
diff --git a/roundup/cgi/client.py b/roundup/cgi/client.py
new file mode 100644 (file)
index 0000000..bcc0a16
--- /dev/null
@@ -0,0 +1,915 @@
+# $Id: client.py,v 1.1 2002-08-30 08:28:44 richard Exp $
+
+__doc__ = """
+WWW request handler (also used in the stand-alone server).
+"""
+
+import os, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
+import binascii, Cookie, time, random
+
+from roundup import roundupdb, date, hyperdb, password
+from roundup.i18n import _
+
+from roundup.cgi.templating import RoundupPageTemplate
+from roundup.cgi import cgitb
+from PageTemplates import PageTemplate
+
+class Unauthorised(ValueError):
+    pass
+
+class NotFound(ValueError):
+    pass
+
+class Redirect(Exception):
+    pass
+
+class SendFile(Exception):
+    ' Sent a file from the database '
+
+class SendStaticFile(Exception):
+    ' Send a static file from the instance html directory '
+
+def initialiseSecurity(security):
+    ''' Create some Permissions and Roles on the security object
+
+        This function is directly invoked by security.Security.__init__()
+        as a part of the Security object instantiation.
+    '''
+    security.addPermission(name="Web Registration",
+        description="User may register through the web")
+    p = security.addPermission(name="Web Access",
+        description="User may access the web interface")
+    security.addPermissionToRole('Admin', p)
+
+    # doing Role stuff through the web - make sure Admin can
+    p = security.addPermission(name="Web Roles",
+        description="User may manipulate user Roles through the web")
+    security.addPermissionToRole('Admin', p)
+
+class Client:
+    '''
+    A note about login
+    ------------------
+
+    If the user has no login cookie, then they are anonymous. There
+    are two levels of anonymous use. If there is no 'anonymous' user, there
+    is no login at all and the database is opened in read-only mode. If the
+    'anonymous' user exists, the user is logged in using that user (though
+    there is no cookie). This allows them to modify the database, and all
+    modifications are attributed to the 'anonymous' user.
+
+    Once a user logs in, they are assigned a session. The Client instance
+    keeps the nodeid of the session as the "session" attribute.
+    '''
+
+    def __init__(self, instance, request, env, form=None):
+        hyperdb.traceMark()
+        self.instance = instance
+        self.request = request
+        self.env = env
+        self.path = env['PATH_INFO']
+        self.split_path = self.path.split('/')
+        self.instance_path_name = env['INSTANCE_NAME']
+        url = self.env['SCRIPT_NAME'] + '/' + self.instance_path_name
+        machine = self.env['SERVER_NAME']
+        port = self.env['SERVER_PORT']
+        if port != '80': machine = machine + ':' + port
+        self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
+            None, None, None))
+
+        if form is None:
+            self.form = cgi.FieldStorage(environ=env)
+        else:
+            self.form = form
+        self.headers_done = 0
+        try:
+            self.debug = int(env.get("ROUNDUP_DEBUG", 0))
+        except ValueError:
+            # someone gave us a non-int debug level, turn it off
+            self.debug = 0
+
+    def main(self):
+        ''' Wrap the request and handle unauthorised requests
+        '''
+        self.content_action = None
+        self.ok_message = []
+        self.error_message = []
+        try:
+            # make sure we're identified (even anonymously)
+            self.determine_user()
+            # figure out the context and desired content template
+            self.determine_context()
+            # possibly handle a form submit action (may change self.message
+            # and self.template_name)
+            self.handle_action()
+            # now render the page
+            self.write(self.template('page', ok_message=self.ok_message,
+                error_message=self.error_message))
+        except Redirect, url:
+            # let's redirect - if the url isn't None, then we need to do
+            # the headers, otherwise the headers have been set before the
+            # exception was raised
+            if url:
+                self.header({'Location': url}, response=302)
+        except SendFile, designator:
+            self.serve_file(designator)
+        except SendStaticFile, file:
+            self.serve_static_file(file)
+        except Unauthorised, message:
+            self.write(self.template('page.unauthorised',
+                error_message=message))
+        except:
+            # everything else
+            self.write(cgitb.html())
+
+    def determine_user(self):
+        ''' Determine who the user is
+        '''
+        # determine the uid to use
+        self.opendb('admin')
+
+        # make sure we have the session Class
+        sessions = self.db.sessions
+
+        # age sessions, remove when they haven't been used for a week
+        # TODO: this shouldn't be done every access
+        week = 60*60*24*7
+        now = time.time()
+        for sessid in sessions.list():
+            interval = now - sessions.get(sessid, 'last_use')
+            if interval > week:
+                sessions.destroy(sessid)
+
+        # look up the user session cookie
+        cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
+        user = 'anonymous'
+
+        if (cookie.has_key('roundup_user') and
+                cookie['roundup_user'].value != 'deleted'):
+
+            # get the session key from the cookie
+            self.session = cookie['roundup_user'].value
+            # get the user from the session
+            try:
+                # update the lifetime datestamp
+                sessions.set(self.session, last_use=time.time())
+                sessions.commit()
+                user = sessions.get(self.session, 'user')
+            except KeyError:
+                user = 'anonymous'
+
+        # sanity check on the user still being valid, getting the userid
+        # at the same time
+        try:
+            self.userid = self.db.user.lookup(user)
+        except (KeyError, TypeError):
+            user = 'anonymous'
+
+        # make sure the anonymous user is valid if we're using it
+        if user == 'anonymous':
+            self.make_user_anonymous()
+        else:
+            self.user = user
+
+    def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
+        ''' Determine the context of this page:
+
+             home              (default if no url is given)
+             classname
+             designator        (classname and nodeid)
+
+            The desired template to be rendered is also determined There
+            are two exceptional contexts:
+
+             _file            - serve up a static file
+             path len > 1     - serve up a FileClass content
+                                (the additional path gives the browser a
+                                 nicer filename to save as)
+
+            The template used is specified by the :template CGI variable,
+            which defaults to:
+             only classname suplied:          "index"
+             full item designator supplied:   "item"
+
+            We set:
+             self.classname
+             self.nodeid
+             self.template_name
+        '''
+        # default the optional variables
+        self.classname = None
+        self.nodeid = None
+
+        # determine the classname and possibly nodeid
+        path = self.split_path
+        if not path or path[0] in ('', 'home', 'index'):
+            if self.form.has_key(':template'):
+                self.template_type = self.form[':template'].value
+                self.template_name = 'home' + '.' + self.template_type
+            else:
+                self.template_type = ''
+                self.template_name = 'home'
+            return
+        elif path[0] == '_file':
+            raise SendStaticFile, path[1]
+        else:
+            self.classname = path[0]
+            if len(path) > 1:
+                # send the file identified by the designator in path[0]
+                raise SendFile, path[0]
+
+        # see if we got a designator
+        m = dre.match(self.classname)
+        if m:
+            self.classname = m.group(1)
+            self.nodeid = m.group(2)
+            # with a designator, we default to item view
+            self.template_type = 'item'
+        else:
+            # with only a class, we default to index view
+            self.template_type = 'index'
+
+        # see if we have a template override
+        if self.form.has_key(':template'):
+            self.template_type = self.form[':template'].value
+
+
+        # see if we were passed in a message
+        if self.form.has_key(':ok_message'):
+            self.ok_message.append(self.form[':ok_message'].value)
+        if self.form.has_key(':error_message'):
+            self.error_message.append(self.form[':error_message'].value)
+
+        # we have the template name now
+        self.template_name = self.classname + '.' + self.template_type
+
+    def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
+        ''' Serve the file from the content property of the designated item.
+        '''
+        m = dre.match(str(designator))
+        if not m:
+            raise NotFound, str(designator)
+        classname, nodeid = m.group(1), m.group(2)
+        if classname != 'file':
+            raise NotFound, designator
+
+        # we just want to serve up the file named
+        file = self.db.file
+        self.header({'Content-Type': file.get(nodeid, 'type')})
+        self.write(file.get(nodeid, 'content'))
+
+    def serve_static_file(self, file):
+        # we just want to serve up the file named
+        mt = mimetypes.guess_type(str(file))[0]
+        self.header({'Content-Type': mt})
+        self.write(open('/tmp/test/html/%s'%file).read())
+
+    def template(self, name, **kwargs):
+        ''' Return a PageTemplate for the named page
+        '''
+        pt = RoundupPageTemplate(self)
+        # make errors nicer
+        pt.id = name
+        pt.write(open('/tmp/test/html/%s'%name).read())
+        # XXX handle PT rendering errors here nicely
+        try:
+            return pt.render(**kwargs)
+        except PageTemplate.PTRuntimeError, message:
+            return '<strong>%s</strong><ol>%s</ol>'%(message,
+                cgi.escape('<li>'.join(pt._v_errors)))
+        except:
+            # everything else
+            return cgitb.html()
+
+    def content(self):
+        ''' Callback used by the page template to render the content of 
+            the page.
+        '''
+        # now render the page content using the template we determined in
+        # determine_context
+        return self.template(self.template_name)
+
+    # these are the actions that are available
+    actions = {
+        'edit':     'edititem_action',
+        'new':      'newitem_action',
+        'login':    'login_action',
+        'logout':   'logout_action',
+        'register': 'register_action',
+    }
+    def handle_action(self):
+        ''' Determine whether there should be an _action called.
+
+            The action is defined by the form variable :action which
+            identifies the method on this object to call. The four basic
+            actions are defined in the "actions" dictionary on this class:
+             "edit"      -> self.edititem_action
+             "new"       -> self.newitem_action
+             "login"     -> self.login_action
+             "logout"    -> self.logout_action
+             "register"  -> self.register_action
+
+        '''
+        if not self.form.has_key(':action'):
+            return None
+        try:
+            # get the action, validate it
+            action = self.form[':action'].value
+            if not self.actions.has_key(action):
+                raise ValueError, 'No such action "%s"'%action
+
+            # call the mapped action
+            getattr(self, self.actions[action])()
+        except Redirect:
+            raise
+        except:
+            self.db.rollback()
+            s = StringIO.StringIO()
+            traceback.print_exc(None, s)
+            self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
+
+    def write(self, content):
+        if not self.headers_done:
+            self.header()
+        self.request.wfile.write(content)
+
+    def header(self, headers=None, response=200):
+        '''Put up the appropriate header.
+        '''
+        if headers is None:
+            headers = {'Content-Type':'text/html'}
+        if not headers.has_key('Content-Type'):
+            headers['Content-Type'] = 'text/html'
+        self.request.send_response(response)
+        for entry in headers.items():
+            self.request.send_header(*entry)
+        self.request.end_headers()
+        self.headers_done = 1
+        if self.debug:
+            self.headers_sent = headers
+
+    def set_cookie(self, user, password):
+        # TODO generate a much, much stronger session key ;)
+        self.session = binascii.b2a_base64(repr(time.time())).strip()
+
+        # clean up the base64
+        if self.session[-1] == '=':
+            if self.session[-2] == '=':
+                self.session = self.session[:-2]
+            else:
+                self.session = self.session[:-1]
+
+        # insert the session in the sessiondb
+        self.db.sessions.set(self.session, user=user, last_use=time.time())
+
+        # and commit immediately
+        self.db.sessions.commit()
+
+        # expire us in a long, long time
+        expire = Cookie._getdate(86400*365)
+
+        # generate the cookie path - make sure it has a trailing '/'
+        path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
+            ''))
+        self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
+            self.session, expire, path)})
+
+    def make_user_anonymous(self):
+        ''' Make us anonymous
+
+            This method used to handle non-existence of the 'anonymous'
+            user, but that user is mandatory now.
+        '''
+        self.userid = self.db.user.lookup('anonymous')
+        self.user = 'anonymous'
+
+    def logout(self):
+        ''' Make us really anonymous - nuke the cookie too
+        '''
+        self.make_user_anonymous()
+
+        # construct the logout cookie
+        now = Cookie._getdate()
+        path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
+            ''))
+        self.header({'Set-Cookie':
+            'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
+            path)})
+        self.login()
+
+    def opendb(self, user):
+        ''' Open the database.
+        '''
+        # open the db if the user has changed
+        if not hasattr(self, 'db') or user != self.db.journaltag:
+            self.db = self.instance.open(user)
+
+    #
+    # Actions
+    #
+    def login_action(self):
+        ''' Attempt to log a user in and set the cookie
+        '''
+        # we need the username at a minimum
+        if not self.form.has_key('__login_name'):
+            self.error_message.append(_('Username required'))
+            return
+
+        self.user = self.form['__login_name'].value
+        # re-open the database for real, using the user
+        self.opendb(self.user)
+        if self.form.has_key('__login_password'):
+            password = self.form['__login_password'].value
+        else:
+            password = ''
+        # make sure the user exists
+        try:
+            self.userid = self.db.user.lookup(self.user)
+        except KeyError:
+            name = self.user
+            self.make_user_anonymous()
+            self.error_message.append(_('No such user "%(name)s"')%locals())
+            return
+
+        # and that the password is correct
+        pw = self.db.user.get(self.userid, 'password')
+        if password != pw:
+            self.make_user_anonymous()
+            self.error_message.append(_('Incorrect password'))
+            return
+
+        # set the session cookie
+        self.set_cookie(self.user, password)
+
+    def logout_action(self):
+        ''' Make us really anonymous - nuke the cookie too
+        '''
+        # log us out
+        self.make_user_anonymous()
+
+        # construct the logout cookie
+        now = Cookie._getdate()
+        path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
+            ''))
+        self.header(headers={'Set-Cookie':
+            'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
+#            'Location': self.db.config.DEFAULT_VIEW}, response=301)
+
+        # suboptimal, but will do for now
+        self.ok_message.append(_('You are logged out'))
+        #raise Redirect, None
+
+    def register_action(self):
+        '''Attempt to create a new user based on the contents of the form
+        and then set the cookie.
+
+        return 1 on successful login
+        '''
+        # make sure we're allowed to register
+        userid = self.db.user.lookup(self.user)
+        if not self.db.security.hasPermission('Web Registration', userid):
+            raise Unauthorised, _("You do not have permission to access"\
+                        " %(action)s.")%{'action': 'registration'}
+
+        # re-open the database as "admin"
+        if self.user != 'admin':
+            self.opendb('admin')
+            
+        # create the new user
+        cl = self.db.user
+        try:
+            props = parsePropsFromForm(self.db, cl, self.form)
+            props['roles'] = self.instance.NEW_WEB_USER_ROLES
+            uid = cl.create(**props)
+            self.db.commit()
+        except ValueError, message:
+            self.error_message.append(message)
+
+        # log the new user in
+        self.user = cl.get(uid, 'username')
+        # re-open the database for real, using the user
+        self.opendb(self.user)
+        password = cl.get(uid, 'password')
+        self.set_cookie(self.user, password)
+
+        # nice message
+        self.ok_message.append(_('You are now registered, welcome!'))
+
+    def edititem_action(self):
+        ''' Perform an edit of an item in the database.
+
+            Some special form elements:
+
+            :link=designator:property
+            :multilink=designator:property
+             The value specifies a node designator and the property on that
+             node to add _this_ node to as a link or multilink.
+            __note
+             Create a message and attach it to the current node's
+             "messages" property.
+            __file
+             Create a file and attach it to the current node's
+             "files" property. Attach the file to the message created from
+             the __note if it's supplied.
+        '''
+        cn = self.classname
+        cl = self.db.classes[cn]
+
+        # check permission
+        userid = self.db.user.lookup(self.user)
+        if not self.db.security.hasPermission('Edit', userid, cn):
+            self.error_message.append(
+                _('You do not have permission to edit %s' %cn))
+
+        # perform the edit
+        props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
+
+        # make changes to the node
+        props = self._changenode(props)
+
+        # handle linked nodes 
+        self._post_editnode(self.nodeid)
+
+        # commit now that all the tricky stuff is done
+        self.db.commit()
+
+        # and some nice feedback for the user
+        if props:
+            message = _('%(changes)s edited ok')%{'changes':
+                ', '.join(props.keys())}
+        elif self.form.has_key('__note') and self.form['__note'].value:
+            message = _('note added')
+        elif (self.form.has_key('__file') and self.form['__file'].filename):
+            message = _('file added')
+        else:
+            message = _('nothing changed')
+
+        # redirect to the item's edit page
+        raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, cn, self.nodeid,  
+            urllib.quote(message))
+
+    def newitem_action(self):
+        ''' Add a new item to the database.
+
+            This follows the same form as the edititem_action
+        '''
+        # check permission
+        cn = self.classname
+        userid = self.db.user.lookup(self.user)
+        if not self.db.security.hasPermission('Edit', userid, cn):
+            self.error_message.append(
+                _('You do not have permission to create %s' %cn))
+
+        # XXX
+#        cl = self.db.classes[cn]
+#        if self.form.has_key(':multilink'):
+#            link = self.form[':multilink'].value
+#            designator, linkprop = link.split(':')
+#            xtra = ' for <a href="%s">%s</a>' % (designator, designator)
+#        else:
+#            xtra = ''
+
+        try:
+            # do the create
+            nid = self._createnode()
+
+            # handle linked nodes 
+            self._post_editnode(nid)
+
+            # commit now that all the tricky stuff is done
+            self.db.commit()
+
+            # render the newly created item
+            self.nodeid = nid
+
+            # and some nice feedback for the user
+            message = _('%(classname)s created ok')%{'classname': cn}
+        except:
+            # oops
+            self.db.rollback()
+            s = StringIO.StringIO()
+            traceback.print_exc(None, s)
+            self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
+
+        # redirect to the new item's page
+        raise Redirect, '%s/%s%s?:ok_message=%s'%(self.base, cn, nid,  
+            urllib.quote(message))
+
+    def genericedit_action(self):
+        ''' Performs an edit of all of a class' items in one go.
+
+            The "rows" CGI var defines the CSV-formatted entries for the
+            class. New nodes are identified by the ID 'X' (or any other
+            non-existent ID) and removed lines are retired.
+        '''
+        userid = self.db.user.lookup(self.user)
+        if not self.db.security.hasPermission('Edit', userid):
+            raise Unauthorised, _("You do not have permission to access"\
+                        " %(action)s.")%{'action': self.classname}
+        w = self.write
+        cn = self.classname
+        cl = self.db.classes[cn]
+        idlessprops = cl.getprops(protected=0).keys()
+        props = ['id'] + idlessprops
+
+        # get the CSV module
+        try:
+            import csv
+        except ImportError:
+            self.error_message.append(_(
+                'Sorry, you need the csv module to use this function.<br>\n'
+                'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
+            return
+
+        # do the edit
+        rows = self.form['rows'].value.splitlines()
+        p = csv.parser()
+        found = {}
+        line = 0
+        for row in rows:
+            line += 1
+            values = p.parse(row)
+            # not a complete row, keep going
+            if not values: continue
+
+            # extract the nodeid
+            nodeid, values = values[0], values[1:]
+            found[nodeid] = 1
+
+            # confirm correct weight
+            if len(idlessprops) != len(values):
+                w(_('Not enough values on line %(line)s'%{'line':line}))
+                return
+
+            # extract the new values
+            d = {}
+            for name, value in zip(idlessprops, values):
+                value = value.strip()
+                # only add the property if it has a value
+                if value:
+                    # if it's a multilink, split it
+                    if isinstance(cl.properties[name], hyperdb.Multilink):
+                        value = value.split(':')
+                    d[name] = value
+
+            # perform the edit
+            if cl.hasnode(nodeid):
+                # edit existing
+                cl.set(nodeid, **d)
+            else:
+                # new node
+                found[cl.create(**d)] = 1
+
+        # retire the removed entries
+        for nodeid in cl.list():
+            if not found.has_key(nodeid):
+                cl.retire(nodeid)
+
+        message = _('items edited OK')
+
+        # redirect to the class' edit page
+        raise Redirect, '%s/%s?:ok_message=%s'%(self.base, cn, 
+            urllib.quote(message))
+
+    def _changenode(self, props):
+        ''' change the node based on the contents of the form
+        '''
+        cl = self.db.classes[self.classname]
+
+        # create the message
+        message, files = self._handle_message()
+        if message:
+            props['messages'] = cl.get(self.nodeid, 'messages') + [message]
+        if files:
+            props['files'] = cl.get(self.nodeid, 'files') + files
+
+        # make the changes
+        return cl.set(self.nodeid, **props)
+
+    def _createnode(self):
+        ''' create a node based on the contents of the form
+        '''
+        cl = self.db.classes[self.classname]
+        props = parsePropsFromForm(self.db, cl, self.form)
+
+        # check for messages and files
+        message, files = self._handle_message()
+        if message:
+            props['messages'] = [message]
+        if files:
+            props['files'] = files
+        # create the node and return it's id
+        return cl.create(**props)
+
+    def _handle_message(self):
+        ''' generate an edit message
+        '''
+        # handle file attachments 
+        files = []
+        if self.form.has_key('__file'):
+            file = self.form['__file']
+            if file.filename:
+                filename = file.filename.split('\\')[-1]
+                mime_type = mimetypes.guess_type(filename)[0]
+                if not mime_type:
+                    mime_type = "application/octet-stream"
+                # create the new file entry
+                files.append(self.db.file.create(type=mime_type,
+                    name=filename, content=file.file.read()))
+
+        # we don't want to do a message if none of the following is true...
+        cn = self.classname
+        cl = self.db.classes[self.classname]
+        props = cl.getprops()
+        note = None
+        # in a nutshell, don't do anything if there's no note or there's no
+        # NOSY
+        if self.form.has_key('__note'):
+            note = self.form['__note'].value.strip()
+        if not note:
+            return None, files
+        if not props.has_key('messages'):
+            return None, files
+        if not isinstance(props['messages'], hyperdb.Multilink):
+            return None, files
+        if not props['messages'].classname == 'msg':
+            return None, files
+        if not (self.form.has_key('nosy') or note):
+            return None, files
+
+        # handle the note
+        if '\n' in note:
+            summary = re.split(r'\n\r?', note)[0]
+        else:
+            summary = note
+        m = ['%s\n'%note]
+
+        # handle the messageid
+        # TODO: handle inreplyto
+        messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
+            self.classname, self.instance.MAIL_DOMAIN)
+
+        # now create the message, attaching the files
+        content = '\n'.join(m)
+        message_id = self.db.msg.create(author=self.userid,
+            recipients=[], date=date.Date('.'), summary=summary,
+            content=content, files=files, messageid=messageid)
+
+        # update the messages property
+        return message_id, files
+
+    def _post_editnode(self, nid):
+        '''Do the linking part of the node creation.
+
+           If a form element has :link or :multilink appended to it, its
+           value specifies a node designator and the property on that node
+           to add _this_ node to as a link or multilink.
+
+           This is typically used on, eg. the file upload page to indicated
+           which issue to link the file to.
+
+           TODO: I suspect that this and newfile will go away now that
+           there's the ability to upload a file using the issue __file form
+           element!
+        '''
+        cn = self.classname
+        cl = self.db.classes[cn]
+        # link if necessary
+        keys = self.form.keys()
+        for key in keys:
+            if key == ':multilink':
+                value = self.form[key].value
+                if type(value) != type([]): value = [value]
+                for value in value:
+                    designator, property = value.split(':')
+                    link, nodeid = hyperdb.splitDesignator(designator)
+                    link = self.db.classes[link]
+                    # take a dupe of the list so we're not changing the cache
+                    value = link.get(nodeid, property)[:]
+                    value.append(nid)
+                    link.set(nodeid, **{property: value})
+            elif key == ':link':
+                value = self.form[key].value
+                if type(value) != type([]): value = [value]
+                for value in value:
+                    designator, property = value.split(':')
+                    link, nodeid = hyperdb.splitDesignator(designator)
+                    link = self.db.classes[link]
+                    link.set(nodeid, **{property: nid})
+
+
+    def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
+        # XXX handle this !
+        target = self.index_arg(':target')[0]
+        m = dre.match(target)
+        if m:
+            classname = m.group(1)
+            nodeid = m.group(2)
+            cl = self.db.getclass(classname)
+            cl.retire(nodeid)
+            # now take care of the reference
+            parentref =  self.index_arg(':multilink')[0]
+            parent, prop = parentref.split(':')
+            m = dre.match(parent)
+            if m:
+                self.classname = m.group(1)
+                self.nodeid = m.group(2)
+                cl = self.db.getclass(self.classname)
+                value = cl.get(self.nodeid, prop)
+                value.remove(nodeid)
+                cl.set(self.nodeid, **{prop:value})
+                func = getattr(self, 'show%s'%self.classname)
+                return func()
+            else:
+                raise NotFound, parent
+        else:
+            raise NotFound, target
+
+
+def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
+    '''Pull properties for the given class out of the form.
+    '''
+    props = {}
+    keys = form.keys()
+    for key in keys:
+        if not cl.properties.has_key(key):
+            continue
+        proptype = cl.properties[key]
+        if isinstance(proptype, hyperdb.String):
+            value = form[key].value.strip()
+        elif isinstance(proptype, hyperdb.Password):
+            value = password.Password(form[key].value.strip())
+        elif isinstance(proptype, hyperdb.Date):
+            value = form[key].value.strip()
+            if value:
+                value = date.Date(form[key].value.strip())
+            else:
+                value = None
+        elif isinstance(proptype, hyperdb.Interval):
+            value = form[key].value.strip()
+            if value:
+                value = date.Interval(form[key].value.strip())
+            else:
+                value = None
+        elif isinstance(proptype, hyperdb.Link):
+            value = form[key].value.strip()
+            # see if it's the "no selection" choice
+            if value == '-1':
+                value = None
+            else:
+                # handle key values
+                link = cl.properties[key].classname
+                if not num_re.match(value):
+                    try:
+                        value = db.classes[link].lookup(value)
+                    except KeyError:
+                        raise ValueError, _('property "%(propname)s": '
+                            '%(value)s not a %(classname)s')%{'propname':key, 
+                            'value': value, 'classname': link}
+        elif isinstance(proptype, hyperdb.Multilink):
+            value = form[key]
+            if hasattr(value, 'value'):
+                # Quite likely to be a FormItem instance
+                value = value.value
+            if not isinstance(value, type([])):
+                value = [i.strip() for i in value.split(',')]
+            else:
+                value = [i.strip() for i in value]
+            link = cl.properties[key].classname
+            l = []
+            for entry in map(str, value):
+                if entry == '': continue
+                if not num_re.match(entry):
+                    try:
+                        entry = db.classes[link].lookup(entry)
+                    except KeyError:
+                        raise ValueError, _('property "%(propname)s": '
+                            '"%(value)s" not an entry of %(classname)s')%{
+                            'propname':key, 'value': entry, 'classname': link}
+                l.append(entry)
+            l.sort()
+            value = l
+        elif isinstance(proptype, hyperdb.Boolean):
+            value = form[key].value.strip()
+            props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
+        elif isinstance(proptype, hyperdb.Number):
+            value = form[key].value.strip()
+            props[key] = value = int(value)
+
+        # get the old value
+        if nodeid:
+            try:
+                existing = cl.get(nodeid, key)
+            except KeyError:
+                # this might be a new property for which there is no existing
+                # value
+                if not cl.properties.has_key(key): raise
+
+            # if changed, set it
+            if value != existing:
+                props[key] = value
+        else:
+            props[key] = value
+    return props
+
+
diff --git a/roundup/cgi/templating.py b/roundup/cgi/templating.py
new file mode 100644 (file)
index 0000000..d109d88
--- /dev/null
@@ -0,0 +1,998 @@
+import sys, cgi, urllib
+
+from roundup import hyperdb, date
+from roundup.i18n import _
+
+
+try:
+    import StructuredText
+except ImportError:
+    StructuredText = None
+
+# Make sure these modules are loaded
+# I need these to run PageTemplates outside of Zope :(
+# If we're running in a Zope environment, these modules will be loaded
+# already...
+if not sys.modules.has_key('zLOG'):
+    import zLOG
+    sys.modules['zLOG'] = zLOG
+if not sys.modules.has_key('MultiMapping'):
+    import MultiMapping
+    sys.modules['MultiMapping'] = MultiMapping
+if not sys.modules.has_key('ComputedAttribute'):
+    import ComputedAttribute
+    sys.modules['ComputedAttribute'] = ComputedAttribute
+if not sys.modules.has_key('ExtensionClass'):
+    import ExtensionClass
+    sys.modules['ExtensionClass'] = ExtensionClass
+if not sys.modules.has_key('Acquisition'):
+    import Acquisition
+    sys.modules['Acquisition'] = Acquisition
+
+# now it's safe to import PageTemplates and ZTUtils
+from PageTemplates import PageTemplate
+import ZTUtils
+
+class RoundupPageTemplate(PageTemplate.PageTemplate):
+    ''' A Roundup-specific PageTemplate.
+
+        Interrogate the client to set up the various template variables to
+        be available:
+
+        *class*
+          The current class of node being displayed as an HTMLClass
+          instance.
+        *item*
+          The current node from the database, if we're viewing a specific
+          node, as an HTMLItem instance. If it doesn't exist, then we're
+          on a new item page.
+        (*classname*)
+          this is one of two things:
+
+          1. the *item* is also available under its classname, so a *user*
+             node would also be available under the name *user*. This is
+             also an HTMLItem instance.
+          2. if there's no *item* then the current class is available
+             through this name, thus "user/name" and "user/name/menu" will
+             still work - the latter will pull information from the form
+             if it can.
+        *form*
+          The current CGI form information as a mapping of form argument
+          name to value
+        *request*
+          Includes information about the current request, including:
+           - the url
+           - the current index information (``filterspec``, ``filter`` args,
+             ``properties``, etc) parsed out of the form. 
+           - methods for easy filterspec link generation
+           - *user*, the current user node as an HTMLItem instance
+        *instance*
+          The current instance
+        *db*
+          The current database, through which db.config may be reached.
+
+        Maybe also:
+
+        *modules*
+          python modules made available (XXX: not sure what's actually in
+          there tho)
+    '''
+    def __init__(self, client, classname=None, request=None):
+        ''' Extract the vars from the client and install in the context.
+        '''
+        self.client = client
+        self.classname = classname or self.client.classname
+        self.request = request or HTMLRequest(self.client)
+
+    def pt_getContext(self):
+        c = {
+             'klass': HTMLClass(self.client, self.classname),
+             'options': {},
+             'nothing': None,
+             'request': self.request,
+             'content': self.client.content,
+             'db': HTMLDatabase(self.client),
+             'instance': self.client.instance
+        }
+        # add in the item if there is one
+        if self.client.nodeid:
+            c['item'] = HTMLItem(self.client.db, self.classname,
+                self.client.nodeid)
+            c[self.classname] = c['item']
+        else:
+            c[self.classname] = c['klass']
+        return c
+   
+    def render(self, *args, **kwargs):
+        if not kwargs.has_key('args'):
+            kwargs['args'] = args
+        return self.pt_render(extra_context={'options': kwargs})
+
+class HTMLDatabase:
+    ''' Return HTMLClasses for valid class fetches
+    '''
+    def __init__(self, client):
+        self.client = client
+        self.config = client.db.config
+    def __getattr__(self, attr):
+        self.client.db.getclass(attr)
+        return HTMLClass(self.client, attr)
+    def classes(self):
+        l = self.client.db.classes.keys()
+        l.sort()
+        return [HTMLClass(self.client, cn) for cn in l]
+        
+class HTMLClass:
+    ''' Accesses through a class (either through *class* or *db.<classname>*)
+    '''
+    def __init__(self, client, classname):
+        self.client = client
+        self.db = client.db
+        self.classname = classname
+        if classname is not None:
+            self.klass = self.db.getclass(self.classname)
+            self.props = self.klass.getprops()
+
+    def __repr__(self):
+        return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
+
+    def __getattr__(self, attr):
+        ''' return an HTMLItem instance'''
+        #print 'getattr', (self, attr)
+        if attr == 'creator':
+            return HTMLUser(self.client)
+
+        if not self.props.has_key(attr):
+            raise AttributeError, attr
+        prop = self.props[attr]
+
+        # look up the correct HTMLProperty class
+        for klass, htmlklass in propclasses:
+            if isinstance(prop, hyperdb.Multilink):
+                value = []
+            else:
+                value = None
+            if isinstance(prop, klass):
+                return htmlklass(self.db, '', prop, attr, value)
+
+        # no good
+        raise AttributeError, attr
+
+    def properties(self):
+        ''' Return HTMLProperty for all props
+        '''
+        l = []
+        for name, prop in self.props.items():
+            for klass, htmlklass in propclasses:
+                if isinstance(prop, hyperdb.Multilink):
+                    value = []
+                else:
+                    value = None
+                if isinstance(prop, klass):
+                    l.append(htmlklass(self.db, '', prop, name, value))
+        return l
+
+    def list(self):
+        l = [HTMLItem(self.db, self.classname, x) for x in self.klass.list()]
+        return l
+
+    def filter(self, request=None):
+        ''' Return a list of items from this class, filtered and sorted
+            by the current requested filterspec/filter/sort/group args
+        '''
+        if request is not None:
+            filterspec = request.filterspec
+            sort = request.sort
+            group = request.group
+        l = [HTMLItem(self.db, self.classname, x)
+             for x in self.klass.filter(None, filterspec, sort, group)]
+        return l
+
+    def classhelp(self, properties, label='?', width='400', height='400'):
+        '''pop up a javascript window with class help
+
+           This generates a link to a popup window which displays the 
+           properties indicated by "properties" of the class named by
+           "classname". The "properties" should be a comma-separated list
+           (eg. 'id,name,description').
+
+           You may optionally override the label displayed, the width and
+           height. The popup window will be resizable and scrollable.
+        '''
+        return '<a href="javascript:help_window(\'classhelp?classname=%s&' \
+            'properties=%s\', \'%s\', \'%s\')"><b>(%s)</b></a>'%(self.classname,
+            properties, width, height, label)
+
+    def history(self):
+        return 'New node - no history'
+
+    def renderWith(self, name, **kwargs):
+        ''' Render this class with the given template.
+        '''
+        # create a new request and override the specified args
+        req = HTMLRequest(self.client)
+        req.classname = self.classname
+        req.__dict__.update(kwargs)
+
+        # new template, using the specified classname and request
+        pt = RoundupPageTemplate(self.client, self.classname, req)
+
+        # use the specified template
+        name = self.classname + '.' + name
+        pt.write(open('/tmp/test/html/%s'%name).read())
+        pt.id = name
+
+        # XXX handle PT rendering errors here nicely
+        try:
+            return pt.render()
+        except PageTemplate.PTRuntimeError, message:
+            return '<strong>%s</strong><ol>%s</ol>'%(message,
+                cgi.escape('<li>'.join(pt._v_errors)))
+
+class HTMLItem:
+    ''' Accesses through an *item*
+    '''
+    def __init__(self, db, classname, nodeid):
+        self.db = db
+        self.classname = classname
+        self.nodeid = nodeid
+        self.klass = self.db.getclass(classname)
+        self.props = self.klass.getprops()
+
+    def __repr__(self):
+        return '<HTMLItem(0x%x) %s %s>'%(id(self), self.classname, self.nodeid)
+
+    def __getattr__(self, attr):
+        ''' return an HTMLItem instance'''
+        #print 'getattr', (self, attr)
+        if attr == 'id':
+            return self.nodeid
+
+        if not self.props.has_key(attr):
+            raise AttributeError, attr
+        prop = self.props[attr]
+
+        # get the value, handling missing values
+        value = self.klass.get(self.nodeid, attr, None)
+        if value is None:
+            if isinstance(self.props[attr], hyperdb.Multilink):
+                value = []
+
+        # look up the correct HTMLProperty class
+        for klass, htmlklass in propclasses:
+            if isinstance(prop, klass):
+                return htmlklass(self.db, self.nodeid, prop, attr, value)
+
+        # no good
+        raise AttributeError, attr
+
+    # XXX this probably should just return the history items, not the HTML
+    def history(self, direction='descending'):
+        l = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
+            '<tr class="list-header">',
+            _('<th align=left><span class="list-item">Date</span></th>'),
+            _('<th align=left><span class="list-item">User</span></th>'),
+            _('<th align=left><span class="list-item">Action</span></th>'),
+            _('<th align=left><span class="list-item">Args</span></th>'),
+            '</tr>']
+        comments = {}
+        history = self.klass.history(self.nodeid)
+        history.sort()
+        if direction == 'descending':
+            history.reverse()
+        for id, evt_date, user, action, args in history:
+            date_s = str(evt_date).replace("."," ")
+            arg_s = ''
+            if action == 'link' and type(args) == type(()):
+                if len(args) == 3:
+                    linkcl, linkid, key = args
+                    arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
+                        linkcl, linkid, key)
+                else:
+                    arg_s = str(args)
+
+            elif action == 'unlink' and type(args) == type(()):
+                if len(args) == 3:
+                    linkcl, linkid, key = args
+                    arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
+                        linkcl, linkid, key)
+                else:
+                    arg_s = str(args)
+
+            elif type(args) == type({}):
+                cell = []
+                for k in args.keys():
+                    # try to get the relevant property and treat it
+                    # specially
+                    try:
+                        prop = props[k]
+                    except:
+                        prop = None
+                    if prop is not None:
+                        if args[k] and (isinstance(prop, hyperdb.Multilink) or
+                                isinstance(prop, hyperdb.Link)):
+                            # figure what the link class is
+                            classname = prop.classname
+                            try:
+                                linkcl = self.db.getclass(classname)
+                            except KeyError:
+                                labelprop = None
+                                comments[classname] = _('''The linked class
+                                    %(classname)s no longer exists''')%locals()
+                            labelprop = linkcl.labelprop(1)
+#                            hrefable = os.path.exists(
+#                                os.path.join(self.instance.TEMPLATES,
+#                                classname+'.item'))
+
+                        if isinstance(prop, hyperdb.Multilink) and \
+                                len(args[k]) > 0:
+                            ml = []
+                            for linkid in args[k]:
+                                if isinstance(linkid, type(())):
+                                    sublabel = linkid[0] + ' '
+                                    linkids = linkid[1]
+                                else:
+                                    sublabel = ''
+                                    linkids = [linkid]
+                                subml = []
+                                for linkid in linkids:
+                                    label = classname + linkid
+                                    # if we have a label property, try to use it
+                                    # TODO: test for node existence even when
+                                    # there's no labelprop!
+                                    try:
+                                        if labelprop is not None:
+                                            label = linkcl.get(linkid, labelprop)
+                                    except IndexError:
+                                        comments['no_link'] = _('''<strike>The
+                                            linked node no longer
+                                            exists</strike>''')
+                                        subml.append('<strike>%s</strike>'%label)
+                                    else:
+#                                        if hrefable:
+                                        subml.append('<a href="%s%s">%s</a>'%(
+                                            classname, linkid, label))
+                                ml.append(sublabel + ', '.join(subml))
+                            cell.append('%s:\n  %s'%(k, ', '.join(ml)))
+                        elif isinstance(prop, hyperdb.Link) and args[k]:
+                            label = classname + args[k]
+                            # if we have a label property, try to use it
+                            # TODO: test for node existence even when
+                            # there's no labelprop!
+                            if labelprop is not None:
+                                try:
+                                    label = linkcl.get(args[k], labelprop)
+                                except IndexError:
+                                    comments['no_link'] = _('''<strike>The
+                                        linked node no longer
+                                        exists</strike>''')
+                                    cell.append(' <strike>%s</strike>,\n'%label)
+                                    # "flag" this is done .... euwww
+                                    label = None
+                            if label is not None:
+                                if hrefable:
+                                    cell.append('%s: <a href="%s%s">%s</a>\n'%(k,
+                                        classname, args[k], label))
+                                else:
+                                    cell.append('%s: %s' % (k,label))
+
+                        elif isinstance(prop, hyperdb.Date) and args[k]:
+                            d = date.Date(args[k])
+                            cell.append('%s: %s'%(k, str(d)))
+
+                        elif isinstance(prop, hyperdb.Interval) and args[k]:
+                            d = date.Interval(args[k])
+                            cell.append('%s: %s'%(k, str(d)))
+
+                        elif isinstance(prop, hyperdb.String) and args[k]:
+                            cell.append('%s: %s'%(k, cgi.escape(args[k])))
+
+                        elif not args[k]:
+                            cell.append('%s: (no value)\n'%k)
+
+                        else:
+                            cell.append('%s: %s\n'%(k, str(args[k])))
+                    else:
+                        # property no longer exists
+                        comments['no_exist'] = _('''<em>The indicated property
+                            no longer exists</em>''')
+                        cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
+                arg_s = '<br />'.join(cell)
+            else:
+                # unkown event!!
+                comments['unknown'] = _('''<strong><em>This event is not
+                    handled by the history display!</em></strong>''')
+                arg_s = '<strong><em>' + str(args) + '</em></strong>'
+            date_s = date_s.replace(' ', '&nbsp;')
+            l.append('<tr><td nowrap valign=top>%s</td><td valign=top>%s</td>'
+                '<td valign=top>%s</td><td valign=top>%s</td></tr>'%(date_s,
+                user, action, arg_s))
+        if comments:
+            l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
+        for entry in comments.values():
+            l.append('<tr><td colspan=4>%s</td></tr>'%entry)
+        l.append('</table>')
+        return '\n'.join(l)
+
+    def remove(self):
+        # XXX do what?
+        return ''
+
+class HTMLUser(HTMLItem):
+    ''' Accesses through the *user* (a special case of item)
+    '''
+    def __init__(self, client):
+        HTMLItem.__init__(self, client.db, 'user', client.userid)
+        self.default_classname = client.classname
+        self.userid = client.userid
+
+        # used for security checks
+        self.security = client.db.security
+    _marker = []
+    def hasPermission(self, role, classname=_marker):
+        ''' Determine if the user has the Role.
+
+            The class being tested defaults to the template's class, but may
+            be overidden for this test by suppling an alternate classname.
+        '''
+        if classname is self._marker:
+            classname = self.default_classname
+        return self.security.hasPermission(role, self.userid, classname)
+
+class HTMLProperty:
+    ''' String, Number, Date, Interval HTMLProperty
+
+        A wrapper object which may be stringified for the plain() behaviour.
+    '''
+    def __init__(self, db, nodeid, prop, name, value):
+        self.db = db
+        self.nodeid = nodeid
+        self.prop = prop
+        self.name = name
+        self.value = value
+    def __repr__(self):
+        return '<HTMLProperty(0x%x) %s %r %r>'%(id(self), self.name, self.prop, self.value)
+    def __str__(self):
+        return self.plain()
+    def __cmp__(self, other):
+        if isinstance(other, HTMLProperty):
+            return cmp(self.value, other.value)
+        return cmp(self.value, other)
+
+class StringHTMLProperty(HTMLProperty):
+    def plain(self, escape=0):
+        if self.value is None:
+            return ''
+        if escape:
+            return cgi.escape(str(self.value))
+        return str(self.value)
+
+    def stext(self, escape=0):
+        s = self.plain(escape=escape)
+        if not StructuredText:
+            return s
+        return StructuredText(s,level=1,header=0)
+
+    def field(self, size = 30):
+        if self.value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self.value))
+            value = '&quot;'.join(value.split('"'))
+        return '<input name="%s" value="%s" size="%s">'%(self.name, value, size)
+
+    def multiline(self, escape=0, rows=5, cols=40):
+        if self.value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self.value))
+            value = '&quot;'.join(value.split('"'))
+        return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
+            self.name, rows, cols, value)
+
+    def email(self, escape=1):
+        ''' fudge email '''
+        if self.value is None: value = ''
+        else: value = str(self.value)
+        value = value.replace('@', ' at ')
+        value = value.replace('.', ' ')
+        if escape:
+            value = cgi.escape(value)
+        return value
+
+class PasswordHTMLProperty(HTMLProperty):
+    def plain(self):
+        if self.value is None:
+            return ''
+        return _('*encrypted*')
+
+    def field(self, size = 30):
+        return '<input type="password" name="%s" size="%s">'%(self.name, size)
+
+class NumberHTMLProperty(HTMLProperty):
+    def plain(self):
+        return str(self.value)
+
+    def field(self, size = 30):
+        if self.value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self.value))
+            value = '&quot;'.join(value.split('"'))
+        return '<input name="%s" value="%s" size="%s">'%(self.name, value, size)
+
+class BooleanHTMLProperty(HTMLProperty):
+    def plain(self):
+        if self.value is None:
+            return ''
+        return self.value and "Yes" or "No"
+
+    def field(self):
+        checked = self.value and "checked" or ""
+        s = '<input type="radio" name="%s" value="yes" %s>Yes'%(self.name,
+            checked)
+        if checked:
+            checked = ""
+        else:
+            checked = "checked"
+        s += '<input type="radio" name="%s" value="no" %s>No'%(self.name,
+            checked)
+        return s
+
+class DateHTMLProperty(HTMLProperty):
+    def plain(self):
+        if self.value is None:
+            return ''
+        return str(self.value)
+
+    def field(self, size = 30):
+        if self.value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self.value))
+            value = '&quot;'.join(value.split('"'))
+        return '<input name="%s" value="%s" size="%s">'%(self.name, value, size)
+
+    def reldate(self, pretty=1):
+        if not self.value:
+            return ''
+
+        # figure the interval
+        interval = date.Date('.') - self.value
+        if pretty:
+            return interval.pretty()
+        return str(interval)
+
+class IntervalHTMLProperty(HTMLProperty):
+    def plain(self):
+        if self.value is None:
+            return ''
+        return str(self.value)
+
+    def pretty(self):
+        return self.value.pretty()
+
+    def field(self, size = 30):
+        if self.value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self.value))
+            value = '&quot;'.join(value.split('"'))
+        return '<input name="%s" value="%s" size="%s">'%(self.name, value, size)
+
+class LinkHTMLProperty(HTMLProperty):
+    ''' Link HTMLProperty
+        Include the above as well as being able to access the class
+        information. Stringifying the object itself results in the value
+        from the item being displayed. Accessing attributes of this object
+        result in the appropriate entry from the class being queried for the
+        property accessed (so item/assignedto/name would look up the user
+        entry identified by the assignedto property on item, and then the
+        name property of that user)
+    '''
+    def __getattr__(self, attr):
+        ''' return a new HTMLItem '''
+        #print 'getattr', (self, attr, self.value)
+        if not self.value:
+            raise AttributeError, "Can't access missing value"
+        i = HTMLItem(self.db, self.prop.classname, self.value)
+        return getattr(i, attr)
+
+    def plain(self, escape=0):
+        if self.value is None:
+            return _('[unselected]')
+        linkcl = self.db.classes[self.klass.classname]
+        k = linkcl.labelprop(1)
+        value = str(linkcl.get(self.value, k))
+        if escape:
+            value = cgi.escape(value)
+        return value
+
+    # XXX most of the stuff from here down is of dubious utility - it's easy
+    # enough to do in the template by hand (and in some cases, it's shorter
+    # and clearer...
+
+    def field(self):
+        linkcl = self.db.getclass(self.prop.classname)
+        if linkcl.getprops().has_key('order'):  
+            sort_on = 'order'  
+        else:  
+            sort_on = linkcl.labelprop()  
+        options = linkcl.filter(None, {}, [sort_on], []) 
+        # TODO: make this a field display, not a menu one!
+        l = ['<select name="%s">'%property]
+        k = linkcl.labelprop(1)
+        if value is None:
+            s = 'selected '
+        else:
+            s = ''
+        l.append(_('<option %svalue="-1">- no selection -</option>')%s)
+        for optionid in options:
+            option = linkcl.get(optionid, k)
+            s = ''
+            if optionid == value:
+                s = 'selected '
+            if showid:
+                lab = '%s%s: %s'%(self.prop.classname, optionid, option)
+            else:
+                lab = option
+            if size is not None and len(lab) > size:
+                lab = lab[:size-3] + '...'
+            lab = cgi.escape(lab)
+            l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+        l.append('</select>')
+        return '\n'.join(l)
+
+    def download(self, showid=0):
+        linkname = self.prop.classname
+        linkcl = self.db.getclass(linkname)
+        k = linkcl.labelprop(1)
+        linkvalue = cgi.escape(str(linkcl.get(self.value, k)))
+        if showid:
+            label = value
+            title = ' title="%s"'%linkvalue
+            # note ... this should be urllib.quote(linkcl.get(value, k))
+        else:
+            label = linkvalue
+            title = ''
+        return '<a href="%s%s/%s"%s>%s</a>'%(linkname, self.value,
+            linkvalue, title, label)
+
+    def menu(self, size=None, height=None, showid=0, additional=[],
+            **conditions):
+        value = self.value
+
+        # sort function
+        sortfunc = make_sort_function(self.db, self.prop.classname)
+
+        # force the value to be a single choice
+        if isinstance(value, type('')):
+            value = value[0]
+        linkcl = self.db.getclass(self.prop.classname)
+        l = ['<select name="%s">'%self.name]
+        k = linkcl.labelprop(1)
+        s = ''
+        if value is None:
+            s = 'selected '
+        l.append(_('<option %svalue="-1">- no selection -</option>')%s)
+        if linkcl.getprops().has_key('order'):  
+            sort_on = 'order'  
+        else:  
+            sort_on = linkcl.labelprop() 
+        options = linkcl.filter(None, conditions, [sort_on], []) 
+        for optionid in options:
+            option = linkcl.get(optionid, k)
+            s = ''
+            if value in [optionid, option]:
+                s = 'selected '
+            if showid:
+                lab = '%s%s: %s'%(self.prop.classname, optionid, option)
+            else:
+                lab = option
+            if size is not None and len(lab) > size:
+                lab = lab[:size-3] + '...'
+            if additional:
+                m = []
+                for propname in additional:
+                    m.append(linkcl.get(optionid, propname))
+                lab = lab + ' (%s)'%', '.join(map(str, m))
+            lab = cgi.escape(lab)
+            l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+        l.append('</select>')
+        return '\n'.join(l)
+
+#    def checklist(self, ...)
+
+class MultilinkHTMLProperty(HTMLProperty):
+    ''' Multilink HTMLProperty
+
+        Also be iterable, returning a wrapper object like the Link case for
+        each entry in the multilink.
+    '''
+    def __len__(self):
+        ''' length of the multilink '''
+        return len(self.value)
+
+    def __getattr__(self, attr):
+        ''' no extended attribute accesses make sense here '''
+        raise AttributeError, attr
+
+    def __getitem__(self, num):
+        ''' iterate and return a new HTMLItem '''
+        #print 'getitem', (self, num)
+        value = self.value[num]
+        return HTMLItem(self.db, self.prop.classname, value)
+
+    def plain(self, escape=0):
+        linkcl = self.db.classes[self.prop.classname]
+        k = linkcl.labelprop(1)
+        labels = []
+        for v in self.value:
+            labels.append(linkcl.get(v, k))
+        value = ', '.join(labels)
+        if escape:
+            value = cgi.escape(value)
+        return value
+
+    # XXX most of the stuff from here down is of dubious utility - it's easy
+    # enough to do in the template by hand (and in some cases, it's shorter
+    # and clearer...
+
+    def field(self, size=30, showid=0):
+        sortfunc = make_sort_function(self.db, self.prop.classname)
+        linkcl = self.db.getclass(self.prop.classname)
+        value = self.value[:]
+        if value:
+            value.sort(sortfunc)
+        # map the id to the label property
+        if not showid:
+            k = linkcl.labelprop(1)
+            value = [linkcl.get(v, k) for v in value]
+        value = cgi.escape(','.join(value))
+        return '<input name="%s" size="%s" value="%s">'%(self.name, size, value)
+
+    def menu(self, size=None, height=None, showid=0, additional=[],
+            **conditions):
+        value = self.value
+
+        # sort function
+        sortfunc = make_sort_function(self.db, self.prop.classname)
+
+        linkcl = self.db.getclass(self.prop.classname)
+        if linkcl.getprops().has_key('order'):  
+            sort_on = 'order'  
+        else:  
+            sort_on = linkcl.labelprop()
+        options = linkcl.filter(None, conditions, [sort_on], []) 
+        height = height or min(len(options), 7)
+        l = ['<select multiple name="%s" size="%s">'%(self.name, height)]
+        k = linkcl.labelprop(1)
+        for optionid in options:
+            option = linkcl.get(optionid, k)
+            s = ''
+            if optionid in value or option in value:
+                s = 'selected '
+            if showid:
+                lab = '%s%s: %s'%(self.prop.classname, optionid, option)
+            else:
+                lab = option
+            if size is not None and len(lab) > size:
+                lab = lab[:size-3] + '...'
+            if additional:
+                m = []
+                for propname in additional:
+                    m.append(linkcl.get(optionid, propname))
+                lab = lab + ' (%s)'%', '.join(m)
+            lab = cgi.escape(lab)
+            l.append('<option %svalue="%s">%s</option>'%(s, optionid,
+                lab))
+        l.append('</select>')
+        return '\n'.join(l)
+
+# set the propclasses for HTMLItem
+propclasses = (
+    (hyperdb.String, StringHTMLProperty),
+    (hyperdb.Number, NumberHTMLProperty),
+    (hyperdb.Boolean, BooleanHTMLProperty),
+    (hyperdb.Date, DateHTMLProperty),
+    (hyperdb.Interval, IntervalHTMLProperty),
+    (hyperdb.Password, PasswordHTMLProperty),
+    (hyperdb.Link, LinkHTMLProperty),
+    (hyperdb.Multilink, MultilinkHTMLProperty),
+)
+
+def make_sort_function(db, classname):
+    '''Make a sort function for a given class
+    '''
+    linkcl = db.getclass(classname)
+    if linkcl.getprops().has_key('order'):
+        sort_on = 'order'
+    else:
+        sort_on = linkcl.labelprop()
+    def sortfunc(a, b, linkcl=linkcl, sort_on=sort_on):
+        return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
+    return sortfunc
+
+def handleListCGIValue(value):
+    ''' Value is either a single item or a list of items. Each item has a
+        .value that we're actually interested in.
+    '''
+    if isinstance(value, type([])):
+        return [value.value for value in value]
+    else:
+        return value.value.split(',')
+
+# XXX This is starting to look a lot (in data terms) like the client object
+# itself!
+class HTMLRequest:
+    ''' The *request*, holding the CGI form and environment.
+
+    '''
+    def __init__(self, client):
+        self.client = client
+
+        # easier access vars
+        self.form = client.form
+        self.env = client.env
+        self.base = client.base
+        self.user = HTMLUser(client)
+
+        # store the current class name and action
+        self.classname = client.classname
+        self.template_type = client.template_type
+
+        # extract the index display information from the form
+        self.columns = {}
+        if self.form.has_key(':columns'):
+            for entry in handleListCGIValue(self.form[':columns']):
+                self.columns[entry] = 1
+        self.sort = []
+        if self.form.has_key(':sort'):
+            self.sort = handleListCGIValue(self.form[':sort'])
+        self.group = []
+        if self.form.has_key(':group'):
+            self.group = handleListCGIValue(self.form[':group'])
+        self.filter = []
+        if self.form.has_key(':filter'):
+            self.filter = handleListCGIValue(self.form[':filter'])
+        self.filterspec = {}
+        for name in self.filter:
+            if self.form.has_key(name):
+                self.filterspec[name]=handleListCGIValue(self.form[name])
+
+    def __str__(self):
+        d = {}
+        d.update(self.__dict__)
+        f = ''
+        for k in self.form.keys():
+            f += '\n      %r=%r'%(k,handleListCGIValue(self.form[k]))
+        d['form'] = f
+        e = ''
+        for k,v in self.env.items():
+            e += '\n     %r=%r'%(k, v)
+        d['env'] = e
+        return '''
+form: %(form)s
+base: %(base)r
+classname: %(classname)r
+template_type: %(template_type)r
+columns: %(columns)r
+sort: %(sort)r
+group: %(group)r
+filter: %(filter)r
+filterspec: %(filterspec)r
+env: %(env)s
+'''%d
+
+    def indexargs_form(self):
+        ''' return the current index args as form elements '''
+        l = []
+        s = '<input type="hidden" name="%s" value="%s">'
+        if self.columns:
+            l.append(s%(':columns', ','.join(self.columns.keys())))
+        if self.sort:
+            l.append(s%(':sort', ','.join(self.sort)))
+        if self.group:
+            l.append(s%(':group', ','.join(self.group)))
+        if self.filter:
+            l.append(s%(':filter', ','.join(self.filter)))
+        for k,v in self.filterspec.items():
+            l.append(s%(k, ','.join(v)))
+        return '\n'.join(l)
+
+    def indexargs_href(self, url, args):
+        l = ['%s=%s'%(k,v) for k,v in args.items()]
+        if self.columns:
+            l.append(':columns=%s'%(','.join(self.columns.keys())))
+        if self.sort:
+            l.append(':sort=%s'%(','.join(self.sort)))
+        if self.group:
+            l.append(':group=%s'%(','.join(self.group)))
+        if self.filter:
+            l.append(':filter=%s'%(','.join(self.filter)))
+        for k,v in self.filterspec.items():
+            l.append('%s=%s'%(k, ','.join(v)))
+        return '%s?%s'%(url, '&'.join(l))
+
+    def base_javascript(self):
+        return '''
+<script language="javascript">
+submitted = false;
+function submit_once() {
+    if (submitted) {
+        alert("Your request is being processed.\\nPlease be patient.");
+        return 0;
+    }
+    submitted = true;
+    return 1;
+}
+
+function help_window(helpurl, width, height) {
+    HelpWin = window.open('%s/' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
+}
+</script>
+'''%self.base
+
+    def batch(self):
+        ''' Return a batch object for results from the "current search"
+        '''
+        filterspec = self.filterspec
+        sort = self.sort
+        group = self.group
+
+        # get the list of ids we're batching over
+        klass = self.client.db.getclass(self.classname)
+        l = klass.filter(None, filterspec, sort, group)
+
+        # figure batch args
+        if self.form.has_key(':pagesize'):
+            size = int(self.form[':pagesize'].value)
+        else:
+            size = 50
+        if self.form.has_key(':startwith'):
+            start = int(self.form[':startwith'].value)
+        else:
+            start = 0
+
+        # return the batch object
+        return Batch(self.client, self.classname, l, size, start)
+
+class Batch(ZTUtils.Batch):
+    def __init__(self, client, classname, l, size, start, end=0, orphan=0, overlap=0):
+        self.client = client
+        self.classname = classname
+        ZTUtils.Batch.__init__(self, l, size, start, end, orphan, overlap)
+
+    # overwrite so we can late-instantiate the HTMLItem instance
+    def __getitem__(self, index):
+        if index < 0:
+            if index + self.end < self.first: raise IndexError, index
+            return self._sequence[index + self.end]
+        
+        if index >= self.length: raise IndexError, index
+
+        # wrap the return in an HTMLItem
+        return HTMLItem(self.client.db, self.classname,
+            self._sequence[index+self.first])
+
+    # override these 'cos we don't have access to acquisition
+    def previous(self):
+        print self.start
+        if self.start == 1:
+            return None
+        return Batch(self.client, self.classname, self._sequence, self._size,
+            self.first - self._size + self.overlap, 0, self.orphan,
+            self.overlap)
+
+    def next(self):
+        try:
+            self._sequence[self.end]
+        except IndexError:
+            return None
+        return Batch(self.client, self.classname, self._sequence, self._size,
+            self.end - self.overlap, 0, self.orphan, self.overlap)
+
+    def length(self):
+        self.sequence_length = l = len(self._sequence)
+        return l
+
diff --git a/roundup/cgi/zLOG.py b/roundup/cgi/zLOG.py
new file mode 100644 (file)
index 0000000..cb13192
--- /dev/null
@@ -0,0 +1,2 @@
+def LOG(*args, **kw):
+    pass