Code

Fix last commit to make editing/creating items work again.
[roundup.git] / roundup / cgi / client.py
index 0c8deac2f835e7f27038d5466eb401819fa8c914..7722865cb365af31b9568eaa4ec6861bec8713de 100644 (file)
@@ -1,40 +1,25 @@
-# $Id: client.py,v 1.61 2002-12-10 23:39:40 richard Exp $
+# $Id: client.py,v 1.158 2004-02-14 01:17:38 jlgijsbers Exp $
 
-__doc__ = """
-WWW request handler (also used in the stand-alone server).
+"""WWW request handler (also used in the stand-alone server).
 """
+__docformat__ = 'restructuredtext'
 
 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
-import binascii, Cookie, time, random
+import binascii, Cookie, time, random, stat, rfc822
 
 from roundup import roundupdb, date, hyperdb, password
 from roundup.i18n import _
-
-from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
-from roundup.cgi import cgitb
-
-from roundup.cgi.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 '
+from roundup.cgi import templating, cgitb
+from roundup.cgi.actions import *
+from roundup.cgi.exceptions import *
+from roundup.cgi.form_parser import FormParser
+from roundup.mailer import Mailer, MessageSendError
 
 def initialiseSecurity(security):
-    ''' Create some Permissions and Roles on the security object
+    '''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.
+    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")
@@ -47,29 +32,44 @@ def initialiseSecurity(security):
         description="User may manipulate user Roles through the web")
     security.addPermissionToRole('Admin', p)
 
+# used to clean messages passed through CGI variables - HTML-escape any tag
+# that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
+# that people can't pass through nasties like <script>, <iframe>, ...
+CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
+def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
+    return mc.sub(clean_message_callback, message)
+def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
+    ''' Strip all non <a>,<i>,<b> and <br> tags from a string
+    '''
+    if ok.has_key(match.group(3).lower()):
+        return match.group(1)
+    return '&lt;%s&gt;'%match.group(2)
+
 class Client:
-    ''' Instantiate to handle one CGI request.
+    '''Instantiate to handle one CGI request.
 
     See inner_main for request processing.
 
     Client attributes at instantiation:
-        "path" is the PATH_INFO inside the instance (with no leading '/')
-        "base" is the base URL for the instance
-        "form" is the cgi form, an instance of FieldStorage from the standard
-               cgi module
-        "additional_headers" is a dictionary of additional HTTP headers that
-               should be sent to the client
-        "response_code" is the HTTP response code to send to the client
+
+    - "path" is the PATH_INFO inside the instance (with no leading '/')
+    - "base" is the base URL for the instance
+    - "form" is the cgi form, an instance of FieldStorage from the standard
+      cgi module
+    - "additional_headers" is a dictionary of additional HTTP headers that
+      should be sent to the client
+    - "response_code" is the HTTP response code to send to the client
 
     During the processing of a request, the following attributes are used:
-        "error_message" holds a list of error messages
-        "ok_message" holds a list of OK messages
-        "session" is the current user session id
-        "user" is the current user's name
-        "userid" is the current user's id
-        "template" is the current :template context
-        "classname" is the current class context name
-        "nodeid" is the current context item id
+
+    - "error_message" holds a list of error messages
+    - "ok_message" holds a list of OK messages
+    - "session" is the current user session id
+    - "user" is the current user's name
+    - "userid" is the current user's id
+    - "template" is the current :template context
+    - "classname" is the current class context name
+    - "nodeid" is the current context item id
 
     User Identification:
      If the user has no login cookie, then they are anonymous and are logged
@@ -78,20 +78,43 @@ class Client:
 
      Once a user logs in, they are assigned a session. The Client instance
      keeps the nodeid of the session as the "session" attribute.
+
+    Special form variables:
+     Note that in various places throughout this code, special form
+     variables of the form :<name> are used. The colon (":") part may
+     actually be one of either ":" or "@".
     '''
 
+    #
+    # special form variables
+    #
+    FV_TEMPLATE = re.compile(r'[@:]template')
+    FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
+    FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
+
+    # Note: index page stuff doesn't appear here:
+    # columns, sort, sortdir, filter, group, groupdir, search_text,
+    # pagesize, startwith
+
     def __init__(self, instance, request, env, form=None):
         hyperdb.traceMark()
         self.instance = instance
         self.request = request
         self.env = env
+        self.mailer = Mailer(instance.config)
 
         # save off the path
         self.path = env['PATH_INFO']
 
-        # this is the base URL for this instance
+        # this is the base URL for this tracker
         self.base = self.instance.config.TRACKER_WEB
 
+        # this is the "cookie path" for this tracker (ie. the path part of
+        # the "base" url)
+        self.cookie_path = urlparse.urlparse(self.base)[2]
+        self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
+            self.instance.config.TRACKER_NAME)
+
         # see if we need to re-parse the environment for the form (eg Zope)
         if form is None:
             self.form = cgi.FieldStorage(environ=env)
@@ -113,6 +136,7 @@ class Client:
         self.additional_headers = {}
         self.response_code = 200
 
+
     def main(self):
         ''' Wrap the real main in a try/finally so we always close off the db.
         '''
@@ -123,46 +147,60 @@ class Client:
                 self.db.close()
 
     def inner_main(self):
-        ''' Process a request.
-
-            The most common requests are handled like so:
-            1. figure out who we are, defaulting to the "anonymous" user
-               see determine_user
-            2. figure out what the request is for - the context
-               see determine_context
-            3. handle any requested action (item edit, search, ...)
-               see handle_action
-            4. render a template, resulting in HTML output
-
-            In some situations, exceptions occur:
-            - HTTP Redirect  (generally raised by an action)
-            - SendFile       (generally raised by determine_context)
-              serve up a FileClass "content" property
-            - SendStaticFile (generally raised by determine_context)
-              serve up a file from the tracker "html" directory
-            - Unauthorised   (generally raised by an action)
-              the action is cancelled, the request is rendered and an error
-              message is displayed indicating that permission was not
-              granted for the action to take place
-            - NotFound       (raised wherever it needs to be)
-              percolates up to the CGI interface that called the client
+        '''Process a request.
+
+        The most common requests are handled like so:
+
+        1. figure out who we are, defaulting to the "anonymous" user
+           see determine_user
+        2. figure out what the request is for - the context
+           see determine_context
+        3. handle any requested action (item edit, search, ...)
+           see handle_action
+        4. render a template, resulting in HTML output
+
+        In some situations, exceptions occur:
+
+        - HTTP Redirect  (generally raised by an action)
+        - SendFile       (generally raised by determine_context)
+          serve up a FileClass "content" property
+        - SendStaticFile (generally raised by determine_context)
+          serve up a file from the tracker "html" directory
+        - Unauthorised   (generally raised by an action)
+          the action is cancelled, the request is rendered and an error
+          message is displayed indicating that permission was not
+          granted for the action to take place
+        - templating.Unauthorised   (templating action not permitted)
+          raised by an attempted rendering of a template when the user
+          doesn't have permission
+        - NotFound       (raised wherever it needs to be)
+          percolates up to the CGI interface that called the client
         '''
         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
+            # do this first so we don't authenticate for static files
+            # Note: this method opens the database as "admin" in order to
+            # perform context checks
             self.determine_context()
+
+            # make sure we're identified (even anonymously)
+            self.determine_user()
+
             # possibly handle a form submit action (may change self.classname
             # and self.template, and may also append error/ok_messages)
             self.handle_action()
-            # now render the page
 
+            # now render the page
             # we don't want clients caching our dynamic pages
             self.additional_headers['Cache-Control'] = 'no-cache'
-            self.additional_headers['Pragma'] = 'no-cache'
-            self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
+# Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
+#            self.additional_headers['Pragma'] = 'no-cache'
+
+            # expire this page 5 seconds from now
+            date = rfc822.formatdate(time.time() + 5)
+            self.additional_headers['Expires'] = date
 
             # render the content
             self.write(self.renderContext())
@@ -177,19 +215,55 @@ class Client:
         except SendFile, designator:
             self.serve_file(designator)
         except SendStaticFile, file:
-            self.serve_static_file(str(file))
+            try:
+                self.serve_static_file(str(file))
+            except NotModified:
+                # send the 304 response
+                self.request.send_response(304)
+                self.request.end_headers()
         except Unauthorised, message:
-            self.classname=None
-            self.template=''
+            # users may always see the front page
+            self.classname = self.nodeid = None
+            self.template = ''
             self.error_message.append(message)
             self.write(self.renderContext())
         except NotFound:
             # pass through
             raise
+        except FormError, e:
+            self.error_message.append(_('Form Error: ') + str(e))
+            self.write(self.renderContext())
         except:
             # everything else
             self.write(cgitb.html())
 
+    def clean_sessions(self):
+        """Age sessions, remove when they haven't been used for a week.
+        
+        Do it only once an hour.
+
+        Note: also cleans One Time Keys, and other "session" based stuff.
+        """
+        sessions = self.db.sessions
+        last_clean = sessions.get('last_clean', 'last_use') or 0
+
+        week = 60*60*24*7
+        hour = 60*60
+        now = time.time()
+        if now - last_clean > hour:
+            # remove aged sessions
+            for sessid in sessions.list():
+                interval = now - sessions.get(sessid, 'last_use')
+                if interval > week:
+                    sessions.destroy(sessid)
+            # remove aged otks
+            otks = self.db.otks
+            for sessid in otks.list():
+                interval = now - otks.get(sessid, '__time')
+                if interval > week:
+                    otks.destroy(sessid)
+            sessions.set('last_clean', last_use=time.time())
+
     def determine_user(self):
         ''' Determine who the user is
         '''
@@ -197,27 +271,24 @@ class Client:
         self.opendb('admin')
 
         # make sure we have the session Class
+        self.clean_sessions()
         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)
+        # first up, try the REMOTE_USER var (from HTTP Basic Auth handled
+        # by a front-end HTTP server)
+        try:
+            user = os.getenv('REMOTE_USER')
+        except KeyError:
+            pass
 
-        # look up the user session cookie
+        # look up the user session cookie (may override the REMOTE_USER)
         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
         user = 'anonymous'
-
-        # bump the "revision" of the cookie since the format changed
-        if (cookie.has_key('roundup_user_2') and
-                cookie['roundup_user_2'].value != 'deleted'):
+        if (cookie.has_key(self.cookie_name) and
+                cookie[self.cookie_name].value != 'deleted'):
 
             # get the session key from the cookie
-            self.session = cookie['roundup_user_2'].value
+            self.session = cookie[self.cookie_name].value
             # get the user from the session
             try:
                 # update the lifetime datestamp
@@ -225,7 +296,8 @@ class Client:
                 sessions.commit()
                 user = sessions.get(self.session, 'user')
             except KeyError:
-                user = 'anonymous'
+                # not valid, ignore id
+                pass
 
         # sanity check on the user still being valid, getting the userid
         # at the same time
@@ -244,62 +316,86 @@ class Client:
         self.opendb(self.user)
 
     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
-        ''' Determine the context of this page from the URL:
-
-            The URL path after the instance identifier is examined. The path
-            is generally only one entry long.
-
-            - if there is no path, then we are in the "home" context.
-            * if the path is "_file", then the additional path entry
-              specifies the filename of a static file we're to serve up
-              from the instance "html" directory. Raises a SendStaticFile
-              exception.
-            - if there is something in the path (eg "issue"), it identifies
-              the tracker class we're to display.
-            - if the path is an item designator (eg "issue123"), then we're
-              to display a specific item.
-            * if the path starts with an item designator and is longer than
-              one entry, then we're assumed to be handling an item of a
-              FileClass, and the extra path information gives the filename
-              that the client is going to label the download with (ie
-              "file123/image.png" is nicer to download than "file123"). This
-              raises a SendFile exception.
-
-            Both of the "*" types of contexts stop before we bother to
-            determine the template we're going to use. That's because they
-            don't actually use templates.
-
-            The template used is specified by the :template CGI variable,
-            which defaults to:
-
-             only classname suplied:          "index"
-             full item designator supplied:   "item"
-
-            We set:
+        """Determine the context of this page from the URL:
+
+        The URL path after the instance identifier is examined. The path
+        is generally only one entry long.
+
+        - if there is no path, then we are in the "home" context.
+        - if the path is "_file", then the additional path entry
+          specifies the filename of a static file we're to serve up
+          from the instance "html" directory. Raises a SendStaticFile
+          exception.(*)
+        - if there is something in the path (eg "issue"), it identifies
+          the tracker class we're to display.
+        - if the path is an item designator (eg "issue123"), then we're
+          to display a specific item.
+        - if the path starts with an item designator and is longer than
+          one entry, then we're assumed to be handling an item of a
+          FileClass, and the extra path information gives the filename
+          that the client is going to label the download with (ie
+          "file123/image.png" is nicer to download than "file123"). This
+          raises a SendFile exception.(*)
+
+        Both of the "*" types of contexts stop before we bother to
+        determine the template we're going to use. That's because they
+        don't actually use templates.
+
+        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  - the class to display, can be None
+
              self.template   - the template to render the current context with
+
              self.nodeid     - the nodeid of the class we're displaying
-        '''
+        """
         # default the optional variables
         self.classname = None
         self.nodeid = None
 
+        # see if a template or messages are specified
+        template_override = ok_message = error_message = None
+        for key in self.form.keys():
+            if self.FV_TEMPLATE.match(key):
+                template_override = self.form[key].value
+            elif self.FV_OK_MESSAGE.match(key):
+                ok_message = self.form[key].value
+                ok_message = clean_message(ok_message)
+            elif self.FV_ERROR_MESSAGE.match(key):
+                error_message = self.form[key].value
+                error_message = clean_message(error_message)
+
+        # see if we were passed in a message
+        if ok_message:
+            self.ok_message.append(ok_message)
+        if error_message:
+            self.error_message.append(error_message)
+
         # determine the classname and possibly nodeid
         path = self.path.split('/')
         if not path or path[0] in ('', 'home', 'index'):
-            if self.form.has_key(':template'):
-                self.template = self.form[':template'].value
+            if template_override is not None:
+                self.template = template_override
             else:
                 self.template = ''
             return
-        elif path[0] == '_file':
-            raise SendStaticFile, path[1]
+        elif path[0] in ('_file', '@@file'):
+            raise SendStaticFile, os.path.join(*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]
 
+        # we need the db for further context stuff - open it as admin
+        self.opendb('admin')
+
         # see if we got a designator
         m = dre.match(self.classname)
         if m:
@@ -320,14 +416,8 @@ class Client:
             raise NotFound, self.classname
 
         # see if we have a template override
-        if self.form.has_key(':template'):
-            self.template = 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)
+        if template_override is not None:
+            self.template = template_override
 
     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
         ''' Serve the file from the content property of the designated item.
@@ -336,27 +426,79 @@ class Client:
         if not m:
             raise NotFound, str(designator)
         classname, nodeid = m.group(1), m.group(2)
-        if classname != 'file':
+
+        self.opendb('admin')
+        klass = self.db.getclass(classname)
+
+        # make sure we have the appropriate properties
+        props = klass.getprops()
+        if not props.has_key('type'):
             raise NotFound, designator
+        if not props.has_key('content'):
+            raise NotFound, designator
+
+        mime_type = klass.get(nodeid, 'type')
+        content = klass.get(nodeid, 'content')
+        lmt = klass.get(nodeid, 'activity').timestamp()
 
-        # we just want to serve up the file named
-        file = self.db.file
-        self.additional_headers['Content-Type'] = file.get(nodeid, 'type')
-        self.write(file.get(nodeid, 'content'))
+        self._serve_file(lmt, mime_type, content)
 
     def serve_static_file(self, file):
-        # we just want to serve up the file named
-        mt = mimetypes.guess_type(str(file))[0]
-        self.additional_headers['Content-Type'] = mt
-        self.write(open(os.path.join(self.instance.config.TEMPLATES,
-            file)).read())
+        ''' Serve up the file named from the templates dir
+        '''
+        filename = os.path.join(self.instance.config.TEMPLATES, file)
+
+        # last-modified time
+        lmt = os.stat(filename)[stat.ST_MTIME]
+
+        # detemine meta-type
+        file = str(file)
+        mime_type = mimetypes.guess_type(file)[0]
+        if not mime_type:
+            if file.endswith('.css'):
+                mime_type = 'text/css'
+            else:
+                mime_type = 'text/plain'
+
+        # snarf the content
+        f = open(filename, 'rb')
+        try:
+            content = f.read()
+        finally:
+            f.close()
+
+        self._serve_file(lmt, mime_type, content)
+
+    def _serve_file(self, last_modified, mime_type, content):
+        ''' guts of serve_file() and serve_static_file()
+        '''
+        ims = None
+        # see if there's an if-modified-since...
+        if hasattr(self.request, 'headers'):
+            ims = self.request.headers.getheader('if-modified-since')
+        elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
+            # cgi will put the header in the env var
+            ims = self.env['HTTP_IF_MODIFIED_SINCE']
+        if ims:
+            ims = rfc822.parsedate(ims)[:6]
+            lmtt = time.gmtime(lmt)[:6]
+            if lmtt <= ims:
+                raise NotModified
+
+        # spit out headers
+        self.additional_headers['Content-Type'] = mime_type
+        self.additional_headers['Content-Length'] = len(content)
+        lmt = rfc822.formatdate(last_modified)
+        self.additional_headers['Last-Modifed'] = lmt
+        self.write(content)
 
     def renderContext(self):
         ''' Return a PageTemplate for the named page
         '''
         name = self.classname
         extension = self.template
-        pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
+        pt = templating.Templates(self.instance.config.TEMPLATES).get(name,
+            extension)
 
         # catch errors so we can handle PT rendering errors more nicely
         args = {
@@ -365,60 +507,55 @@ class Client:
         }
         try:
             # let the template render figure stuff out
-            return pt.render(self, None, None, **args)
-        except NoTemplate, message:
+            result = pt.render(self, None, None, **args)
+            self.additional_headers['Content-Type'] = pt.content_type
+            return result
+        except templating.NoTemplate, message:
             return '<strong>%s</strong>'%message
+        except templating.Unauthorised, message:
+            raise Unauthorised, str(message)
         except:
             # everything else
             return cgitb.pt_html()
 
     # these are the actions that are available
     actions = (
-        ('edit',     'editItemAction'),
-        ('editCSV',  'editCSVAction'),
-        ('new',      'newItemAction'),
-        ('register', 'registerAction'),
-        ('login',    'loginAction'),
-        ('logout',   'logout_action'),
-        ('search',   'searchAction'),
-        ('retire',   'retireAction'),
+        ('edit',     EditItemAction),
+        ('editcsv',  EditCSVAction),
+        ('new',      NewItemAction),
+        ('register', RegisterAction),
+        ('confrego', ConfRegoAction),
+        ('passrst',  PassResetAction),
+        ('login',    LoginAction),
+        ('logout',   LogoutAction),
+        ('search',   SearchAction),
+        ('retire',   RetireAction),
+        ('show',     ShowAction),
     )
     def handle_action(self):
-        ''' Determine whether there should be an _action called.
+        ''' 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" sequence on this class:
-             "edit"      -> self.editItemAction
-             "new"       -> self.newItemAction
-             "register"  -> self.registerAction
-             "login"     -> self.loginAction
-             "logout"    -> self.logout_action
-             "search"    -> self.searchAction
-             "retire"    -> self.retireAction
+            identifies the method on this object to call. The actions
+            are defined in the "actions" sequence on this class.
         '''
-        if not self.form.has_key(':action'):
+        if self.form.has_key(':action'):
+            action = self.form[':action'].value.lower()
+        elif self.form.has_key('@action'):
+            action = self.form['@action'].value.lower()
+        else:
             return None
         try:
             # get the action, validate it
-            action = self.form[':action'].value
-            for name, method in self.actions:
+            for name, action_klass in self.actions:
                 if name == action:
                     break
             else:
                 raise ValueError, 'No such action "%s"'%action
-
             # call the mapped action
-            getattr(self, method)()
-        except Redirect:
-            raise
-        except Unauthorised:
-            raise
-        except:
-            self.db.rollback()
-            s = StringIO.StringIO()
-            traceback.print_exc(None, s)
-            self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
+            action_klass(self).handle()
+        except ValueError, err:
+            self.error_message.append(str(err))
 
     def write(self, content):
         if not self.headers_done:
@@ -447,9 +584,10 @@ class Client:
             self.headers_sent = headers
 
     def set_cookie(self, user):
-        ''' Set up a session cookie for the user and store away the user's
-            login info against the session.
-        '''
+        """Set up a session cookie for the user.
+
+        Also store away the user's login info against the session.
+        """
         # TODO generate a much, much stronger session key ;)
         self.session = binascii.b2a_base64(repr(random.random())).strip()
 
@@ -470,10 +608,9 @@ class Client:
         expire = Cookie._getdate(86400*365)
 
         # generate the cookie path - make sure it has a trailing '/'
-        path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'],
-            ''))
         self.additional_headers['Set-Cookie'] = \
-          'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path)
+          '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
+            expire, self.cookie_path)
 
     def make_user_anonymous(self):
         ''' Make us anonymous
@@ -493,856 +630,13 @@ class Client:
                 self.db.close()
             self.db = self.instance.open(user)
 
-    #
-    # Actions
-    #
-    def loginAction(self):
-        ''' Attempt to log a user in.
-
-            Sets up a session for the user which contains the login
-            credentials.
-        '''
-        # we need the username at a minimum
-        if not self.form.has_key('__login_name'):
-            self.error_message.append(_('Username required'))
-            return
-
-        # get the login info
-        self.user = self.form['__login_name'].value
-        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.error_message.append(_('No such user "%(name)s"')%locals())
-            self.make_user_anonymous()
-            return
-
-        # verify the password
-        if not self.verifyPassword(self.userid, password):
-            self.make_user_anonymous()
-            self.error_message.append(_('Incorrect password'))
-            return
-
-        # make sure we're allowed to be here
-        if not self.loginPermission():
-            self.make_user_anonymous()
-            self.error_message.append(_("You do not have permission to login"))
-            return
-
-        # now we're OK, re-open the database for real, using the user
-        self.opendb(self.user)
-
-        # set the session cookie
-        self.set_cookie(self.user)
-
-    def verifyPassword(self, userid, password):
-        ''' Verify the password that the user has supplied
-        '''
-        stored = self.db.user.get(self.userid, 'password')
-        if password == stored:
-            return 1
-        if not password and not stored:
-            return 1
-        return 0
-
-    def loginPermission(self):
-        ''' Determine whether the user has permission to log in.
-
-            Base behaviour is to check the user has "Web Access".
-        ''' 
-        if not self.db.security.hasPermission('Web Access', self.userid):
-            return 0
-        return 1
-
-    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['TRACKER_NAME'],
-            ''))
-        self.additional_headers['Set-Cookie'] = \
-           'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)
-
-        # Let the user know what's going on
-        self.ok_message.append(_('You are logged out'))
-
-    def registerAction(self):
-        '''Attempt to create a new user based on the contents of the form
-        and then set the cookie.
-
-        return 1 on successful login
-        '''
-        # create the new user
-        cl = self.db.user
-
-        # parse the props from the form
-        try:
-            props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
-        except (ValueError, KeyError), message:
-            self.error_message.append(_('Error: ') + str(message))
-            return
-
-        # make sure we're allowed to register
-        if not self.registerPermission(props):
-            raise Unauthorised, _("You do not have permission to register")
-
-        # 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.config.NEW_WEB_USER_ROLES
-            self.userid = cl.create(**props)
-            self.db.commit()
-        except (ValueError, KeyError), message:
-            self.error_message.append(message)
-            return
-
-        # log the new user in
-        self.user = cl.get(self.userid, 'username')
-        # re-open the database for real, using the user
-        self.opendb(self.user)
-
-        # if we have a session, update it
-        if hasattr(self, 'session'):
-            self.db.sessions.set(self.session, user=self.user,
-                last_use=time.time())
-        else:
-            # new session cookie
-            self.set_cookie(self.user)
-
-        # nice message
-        message = _('You are now registered, welcome!')
-
-        # redirect to the item's edit page
-        raise Redirect, '%s%s%s?:ok_message=%s'%(
-            self.base, self.classname, self.userid,  urllib.quote(message))
-
-    def registerPermission(self, props):
-        ''' Determine whether the user has permission to register
-
-            Base behaviour is to check the user has "Web Registration".
-        '''
-        # registration isn't allowed to supply roles
-        if props.has_key('roles'):
-            return 0
-        if self.db.security.hasPermission('Web Registration', self.userid):
-            return 1
-        return 0
-
-    def editItemAction(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.
-
-            :required=property,property,...
-             The named properties are required to be filled in the form.
-
-            :remove:<propname>=id(s)
-             The ids will be removed from the multilink property.
-            :add:<propname>=id(s)
-             The ids will be added to the multilink property.
-
-        '''
-        cl = self.db.classes[self.classname]
-
-        # parse the props from the form
-        try:
-            props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
-        except (ValueError, KeyError), message:
-            self.error_message.append(_('Error: ') + str(message))
-            return
-
-        # check permission
-        if not self.editItemPermission(props):
-            self.error_message.append(
-                _('You do not have permission to edit %(classname)s'%
-                self.__dict__))
-            return
-
-        # perform the edit
-        try:
-            # make changes to the node
-            props = self._changenode(props)
-            # handle linked nodes 
-            self._post_editnode(self.nodeid)
-        except (ValueError, KeyError, IndexError), message:
-            self.error_message.append(_('Error: ') + str(message))
-            return
-
-        # 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, self.classname,
-            self.nodeid,  urllib.quote(message))
-
-    def editItemPermission(self, props):
-        ''' Determine whether the user has permission to edit this item.
-
-            Base behaviour is to check the user can edit this class. If we're
-            editing the "user" class, users are allowed to edit their own
-            details. Unless it's the "roles" property, which requires the
-            special Permission "Web Roles".
-        '''
-        # if this is a user node and the user is editing their own node, then
-        # we're OK
-        has = self.db.security.hasPermission
-        if self.classname == 'user':
-            # reject if someone's trying to edit "roles" and doesn't have the
-            # right permission.
-            if props.has_key('roles') and not has('Web Roles', self.userid,
-                    'user'):
-                return 0
-            # if the item being edited is the current user, we're ok
-            if self.nodeid == self.userid:
-                return 1
-        if self.db.security.hasPermission('Edit', self.userid, self.classname):
-            return 1
-        return 0
-
-    def newItemAction(self):
-        ''' Add a new item to the database.
-
-            This follows the same form as the editItemAction, with the same
-            special form values.
-        '''
-        cl = self.db.classes[self.classname]
-
-        # parse the props from the form
-        try:
-            props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
-        except (ValueError, KeyError), message:
-            self.error_message.append(_('Error: ') + str(message))
-            return
-
-        if not self.newItemPermission(props):
-            self.error_message.append(
-                _('You do not have permission to create %s' %self.classname))
-
-        # create a little extra message for anticipated :link / :multilink
-        if self.form.has_key(':multilink'):
-            link = self.form[':multilink'].value
-        elif self.form.has_key(':link'):
-            link = self.form[':multilink'].value
-        else:
-            link = None
-            xtra = ''
-        if link:
-            designator, linkprop = link.split(':')
-            xtra = ' for <a href="%s">%s</a>'%(designator, designator)
-
-        try:
-            # do the create
-            nid = self._createnode(props)
-        except (ValueError, KeyError, IndexError), message:
-            # these errors might just be indicative of user dumbness
-            self.error_message.append(_('Error: ') + str(message))
-            return
-        except:
-            # oops
-            self.db.rollback()
-            s = StringIO.StringIO()
-            traceback.print_exc(None, s)
-            self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
-            return
-
+    def standard_message(self, to, subject, body, author=None):
         try:
-            # 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')%self.__dict__ + xtra
-        except:
-            # oops
-            self.db.rollback()
-            s = StringIO.StringIO()
-            traceback.print_exc(None, s)
-            self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue()))
-            return
-
-        # redirect to the new item's page
-        raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname,
-            nid,  urllib.quote(message))
-
-    def newItemPermission(self, props):
-        ''' Determine whether the user has permission to create (edit) this
-            item.
-
-            Base behaviour is to check the user can edit this class. No
-            additional property checks are made. Additionally, new user items
-            may be created if the user has the "Web Registration" Permission.
-        '''
-        has = self.db.security.hasPermission
-        if self.classname == 'user' and has('Web Registration', self.userid,
-                'user'):
-            return 1
-        if has('Edit', self.userid, self.classname):
+            self.mailer.standard_message(to, subject, body, author)
             return 1
-        return 0
-
-    def editCSVAction(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.
-        '''
-        # this is per-class only
-        if not self.editCSVPermission():
-            self.error_message.append(
-                _('You do not have permission to edit %s' %self.classname))
-
-        # 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
-
-        cl = self.db.classes[self.classname]
-        idlessprops = cl.getprops(protected=0).keys()
-        idlessprops.sort()
-        props = ['id'] + idlessprops
-
-        # do the edit
-        rows = self.form['rows'].value.splitlines()
-        p = csv.parser()
-        found = {}
-        line = 0
-        for row in rows[1:]:
-            line += 1
-            values = p.parse(row)
-            # not a complete row, keep going
-            if not values: continue
-
-            # skip property names header
-            if values == props:
-                continue
-
-            # extract the nodeid
-            nodeid, values = values[0], values[1:]
-            found[nodeid] = 1
-
-            # confirm correct weight
-            if len(idlessprops) != len(values):
-                self.error_message.append(
-                    _('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)
-
-        # all OK
-        self.db.commit()
-
-        self.ok_message.append(_('Items edited OK'))
-
-    def editCSVPermission(self):
-        ''' Determine whether the user has permission to edit this class.
-
-            Base behaviour is to check the user can edit this class.
-        ''' 
-        if not self.db.security.hasPermission('Edit', self.userid,
-                self.classname):
-            return 0
-        return 1
-
-    def searchAction(self):
-        ''' Mangle some of the form variables.
-
-            Set the form ":filter" variable based on the values of the
-            filter variables - if they're set to anything other than
-            "dontcare" then add them to :filter.
-
-            Also handle the ":queryname" variable and save off the query to
-            the user's query list.
-        '''
-        # generic edit is per-class only
-        if not self.searchPermission():
-            self.error_message.append(
-                _('You do not have permission to search %s' %self.classname))
-
-        # add a faked :filter form variable for each filtering prop
-        props = self.db.classes[self.classname].getprops()
-        for key in self.form.keys():
-            if not props.has_key(key): continue
-            if not self.form[key].value: continue
-            self.form.value.append(cgi.MiniFieldStorage(':filter', key))
-
-        # handle saving the query params
-        if self.form.has_key(':queryname'):
-            queryname = self.form[':queryname'].value.strip()
-            if queryname:
-                # parse the environment and figure what the query _is_
-                req = HTMLRequest(self)
-                url = req.indexargs_href('', {})
-
-                # handle editing an existing query
-                try:
-                    qid = self.db.query.lookup(queryname)
-                    self.db.query.set(qid, klass=self.classname, url=url)
-                except KeyError:
-                    # create a query
-                    qid = self.db.query.create(name=queryname,
-                        klass=self.classname, url=url)
-
-                    # and add it to the user's query multilink
-                    queries = self.db.user.get(self.userid, 'queries')
-                    queries.append(qid)
-                    self.db.user.set(self.userid, queries=queries)
-
-                # commit the query change to the database
-                self.db.commit()
-
-    def searchPermission(self):
-        ''' Determine whether the user has permission to search this class.
-
-            Base behaviour is to check the user can view this class.
-        ''' 
-        if not self.db.security.hasPermission('View', self.userid,
-                self.classname):
-            return 0
-        return 1
-
-    def retireAction(self):
-        ''' Retire the context item.
-        '''
-        # if we want to view the index template now, then unset the nodeid
-        # context info (a special-case for retire actions on the index page)
-        nodeid = self.nodeid
-        if self.template == 'index':
-            self.nodeid = None
-
-        # generic edit is per-class only
-        if not self.retirePermission():
-            self.error_message.append(
-                _('You do not have permission to retire %s' %self.classname))
-            return
-
-        # make sure we don't try to retire admin or anonymous
-        if self.classname == 'user' and \
-                self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
-            self.error_message.append(
-                _('You may not retire the admin or anonymous user'))
-            return
-
-        # do the retire
-        self.db.getclass(self.classname).retire(nodeid)
-        self.db.commit()
-
-        self.ok_message.append(
-            _('%(classname)s %(itemid)s has been retired')%{
-                'classname': self.classname.capitalize(), 'itemid': nodeid})
-
-    def retirePermission(self):
-        ''' Determine whether the user has permission to retire this class.
-
-            Base behaviour is to check the user can edit this class.
-        ''' 
-        if not self.db.security.hasPermission('Edit', self.userid,
-                self.classname):
-            return 0
-        return 1
-
-
-    #
-    #  Utility methods for editing
-    #
-    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, props):
-        ''' create a node based on the contents of the form
-        '''
-        cl = self.db.classes[self.classname]
-
-        # 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'):
-            # fix the CRLF/CR -> LF stuff
-            note = fixNewlines(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.config.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 fixNewlines(text):
-    ''' Homogenise line endings.
-
-        Different web clients send different line ending values, but
-        other systems (eg. email) don't necessarily handle those line
-        endings. Our solution is to convert all line endings to LF.
-    '''
-    text = text.replace('\r\n', '\n')
-    return text.replace('\r', '\n')
-
-def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
-    ''' Pull properties for the given class out of the form.
-
-        If a ":required" parameter is supplied, then the names property values
-        must be supplied or a ValueError will be raised.
-
-        Other special form values:
-         :remove:<propname>=id(s)
-          The ids will be removed from the multilink property.
-         :add:<propname>=id(s)
-          The ids will be added to the multilink property.
-    '''
-    required = []
-    if form.has_key(':required'):
-        value = form[':required']
-        if isinstance(value, type([])):
-            required = [i.value.strip() for i in value]
-        else:
-            required = [i.strip() for i in value.value.split(',')]
-
-    props = {}
-    keys = form.keys()
-    properties = cl.getprops()
-    for key in keys:
-        # see if we're performing a special multilink action
-        mlaction = 'set'
-        if key.startswith(':remove:'):
-            propname = key[8:]
-            mlaction = 'remove'
-        elif key.startswith(':add:'):
-            propname = key[5:]
-            mlaction = 'add'
-        else:
-            propname = key
-
-
-        # does the property exist?
-        if not properties.has_key(propname):
-            if mlaction != 'set':
-                raise ValueError, 'You have submitted a remove action for'\
-                    ' the property "%s" which doesn\'t exist'%propname
-            continue
-        proptype = properties[propname]
-
-        # Get the form value. This value may be a MiniFieldStorage or a list
-        # of MiniFieldStorages.
-        value = form[key]
-
-        # make sure non-multilinks only get one value
-        if not isinstance(proptype, hyperdb.Multilink):
-            if isinstance(value, type([])):
-                raise ValueError, 'You have submitted more than one value'\
-                    ' for the %s property'%propname
-            # we've got a MiniFieldStorage, so pull out the value and strip
-            # surrounding whitespace
-            value = value.value.strip()
-
-        if isinstance(proptype, hyperdb.String):
-            if not value:
-                continue
-            # fix the CRLF/CR -> LF stuff
-            value = fixNewlines(value)
-        elif isinstance(proptype, hyperdb.Password):
-            if not value:
-                # ignore empty password values
-                continue
-            if not form.has_key('%s:confirm'%propname):
-                raise ValueError, 'Password and confirmation text do not match'
-            confirm = form['%s:confirm'%propname]
-            if isinstance(confirm, type([])):
-                raise ValueError, 'You have submitted more than one value'\
-                    ' for the %s property'%propname
-            if value != confirm.value:
-                raise ValueError, 'Password and confirmation text do not match'
-            value = password.Password(value)
-        elif isinstance(proptype, hyperdb.Date):
-            if value:
-                value = date.Date(value)
-            else:
-                value = None
-        elif isinstance(proptype, hyperdb.Interval):
-            if value:
-                value = date.Interval(value)
-            else:
-                value = None
-        elif isinstance(proptype, hyperdb.Link):
-            # see if it's the "no selection" choice
-            if value == '-1':
-                # if we're creating, just don't include this property
-                if not nodeid:
-                    continue
-                value = None
-            else:
-                # handle key values
-                link = proptype.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': propname, 'value': value,
-                            'classname': link}
-                    except TypeError, message:
-                        raise ValueError, _('you may only enter ID values '
-                            'for property "%(propname)s": %(message)s')%{
-                            'propname': propname, 'message': message}
-        elif isinstance(proptype, hyperdb.Multilink):
-            if isinstance(value, type([])):
-                # it's a list of MiniFieldStorages
-                value = [i.value.strip() for i in value]
-            else:
-                # it's a MiniFieldStorage, but may be a comma-separated list
-                # of values
-                value = [i.strip() for i in value.value.split(',')]
-            link = proptype.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': propname, 'value': entry,
-                            'classname': link}
-                    except TypeError, message:
-                        raise ValueError, _('you may only enter ID values '
-                            'for property "%(propname)s": %(message)s')%{
-                            'propname': propname, 'message': message}
-                l.append(entry)
-            l.sort()
-
-            # now use that list of ids to modify the multilink
-            if mlaction == 'set':
-                value = l
-            else:
-                # we're modifying the list - get the current list of ids
-                try:
-                    existing = cl.get(nodeid, propname)
-                except KeyError:
-                    existing = []
-                if mlaction == 'remove':
-                    # remove - handle situation where the id isn't in the list
-                    for entry in l:
-                        try:
-                            existing.remove(entry)
-                        except ValueError:
-                            raise ValueError, _('property "%(propname)s": '
-                                '"%(value)s" not currently in list')%{
-                                'propname': propname, 'value': entry}
-                else:
-                    # add - easy, just don't dupe
-                    for entry in l:
-                        if entry not in existing:
-                            existing.append(entry)
-                value = existing
-                value.sort()
-
-        elif isinstance(proptype, hyperdb.Boolean):
-            value = value.lower() in ('yes', 'true', 'on', '1')
-        elif isinstance(proptype, hyperdb.Number):
-            value = int(value)
-
-        # register this as received if required?
-        if propname in required and value is not None:
-            required.remove(propname)
-
-        # get the old value
-        if nodeid:
-            try:
-                existing = cl.get(nodeid, propname)
-            except KeyError:
-                # this might be a new property for which there is no existing
-                # value
-                if not properties.has_key(propname):
-                    raise
-
-            # if changed, set it
-            if value != existing:
-                props[propname] = value
-        else:
-            props[propname] = value
-
-    # see if all the required properties have been supplied
-    if required:
-        if len(required) > 1:
-            p = 'properties'
-        else:
-            p = 'property'
-        raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required))
-
-    return props
+        except MessageSendError, e:
+            self.error_message.append(str(e))
 
+    def parsePropsFromForm(self, create=False):
+        return FormParser(self).parse(create=create)