Code

much nicer error messages when there's a templating error
[roundup.git] / roundup / cgi / client.py
index 6924ad73d5e69bbe08b4463da3094b8564970915..122c0bc07a936cbfe03d271bd0155e5a94ea4b3d 100644 (file)
@@ -1,17 +1,18 @@
-# $Id: client.py,v 1.4 2002-09-01 22:09:20 richard Exp $
+# $Id: client.py,v 1.19 2002-09-06 07:21:31 richard Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
 """
 
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
 """
 
-import os, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
+import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
 import binascii, Cookie, time, random
 
 from roundup import roundupdb, date, hyperdb, password
 from roundup.i18n import _
 
 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.templating import getTemplate, HTMLRequest
 from roundup.cgi import cgitb
 from roundup.cgi import cgitb
+
 from PageTemplates import PageTemplate
 
 class Unauthorised(ValueError):
 from PageTemplates import PageTemplate
 
 class Unauthorised(ValueError):
@@ -99,7 +100,29 @@ class Client:
             self.debug = 0
 
     def main(self):
             self.debug = 0
 
     def main(self):
-        ''' Wrap the request and handle unauthorised requests
+        ''' 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
         '''
         self.content_action = None
         self.ok_message = []
         '''
         self.content_action = None
         self.ok_message = []
@@ -109,12 +132,18 @@ class Client:
             self.determine_user()
             # figure out the context and desired content template
             self.determine_context()
             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)
+            # 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
             self.handle_action()
             # now render the page
-            self.write(self.template('page', ok_message=self.ok_message,
-                error_message=self.error_message))
+            if self.form.has_key(':contentonly'):
+                # just the content
+                self.write(self.content())
+            else:
+                # render the content inside the page template
+                self.write(self.renderTemplate('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
         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
@@ -124,10 +153,9 @@ class Client:
         except SendFile, designator:
             self.serve_file(designator)
         except SendStaticFile, file:
         except SendFile, designator:
             self.serve_file(designator)
         except SendStaticFile, file:
-            self.serve_static_file(file)
+            self.serve_static_file(str(file))
         except Unauthorised, message:
         except Unauthorised, message:
-            self.write(self.template('page.unauthorised',
-                error_message=message))
+            self.write(self.renderTemplate('page', '', error_message=message))
         except:
             # everything else
             self.write(cgitb.html())
         except:
             # everything else
             self.write(cgitb.html())
@@ -154,11 +182,12 @@ class Client:
         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
         user = 'anonymous'
 
         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
         user = 'anonymous'
 
-        if (cookie.has_key('roundup_user') and
-                cookie['roundup_user'].value != 'deleted'):
+        # bump the "revision" of the cookie since the format changed
+        if (cookie.has_key('roundup_user_2') and
+                cookie['roundup_user_2'].value != 'deleted'):
 
             # get the session key from the cookie
 
             # get the session key from the cookie
-            self.session = cookie['roundup_user'].value
+            self.session = cookie['roundup_user_2'].value
             # get the user from the session
             try:
                 # update the lifetime datestamp
             # get the user from the session
             try:
                 # update the lifetime datestamp
@@ -185,29 +214,41 @@ class Client:
         self.opendb(self.user)
 
     def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
         self.opendb(self.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)
+        ''' 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:
 
             The template used is specified by the :template CGI variable,
             which defaults to:
+
              only classname suplied:          "index"
              full item designator supplied:   "item"
 
             We set:
              only classname suplied:          "index"
              full item designator supplied:   "item"
 
             We set:
-             self.classname
-             self.nodeid
-             self.template_name
+             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
         '''
         # default the optional variables
         self.classname = None
@@ -217,11 +258,9 @@ class Client:
         path = self.split_path
         if not path or path[0] in ('', 'home', 'index'):
             if self.form.has_key(':template'):
         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
+                self.template = self.form[':template'].value
             else:
             else:
-                self.template_type = ''
-                self.template_name = 'home'
+                self.template = ''
             return
         elif path[0] == '_file':
             raise SendStaticFile, path[1]
             return
         elif path[0] == '_file':
             raise SendStaticFile, path[1]
@@ -237,14 +276,14 @@ class Client:
             self.classname = m.group(1)
             self.nodeid = m.group(2)
             # with a designator, we default to item view
             self.classname = m.group(1)
             self.nodeid = m.group(2)
             # with a designator, we default to item view
-            self.template_type = 'item'
+            self.template = 'item'
         else:
             # with only a class, we default to index view
         else:
             # with only a class, we default to index view
-            self.template_type = 'index'
+            self.template = 'index'
 
         # see if we have a template override
         if self.form.has_key(':template'):
 
         # see if we have a template override
         if self.form.has_key(':template'):
-            self.template_type = self.form[':template'].value
+            self.template = self.form[':template'].value
 
 
         # see if we were passed in a message
 
 
         # see if we were passed in a message
@@ -253,9 +292,6 @@ class Client:
         if self.form.has_key(':error_message'):
             self.error_message.append(self.form[':error_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.
         '''
     def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
         ''' Serve the file from the content property of the designated item.
         '''
@@ -275,40 +311,47 @@ class Client:
         # we just want to serve up the file named
         mt = mimetypes.guess_type(str(file))[0]
         self.header({'Content-Type': mt})
         # 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())
+        self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
 
 
-    def template(self, name, **kwargs):
+    def renderTemplate(self, name, extension, **kwargs):
         ''' Return a PageTemplate for the named page
         '''
         ''' 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
+        pt = getTemplate(self.instance.TEMPLATES, name, extension)
+        # XXX handle PT rendering errors here more nicely
         try:
         try:
-            return pt.render(**kwargs)
+            # let the template render figure stuff out
+            return pt.render(self, None, None, **kwargs)
         except PageTemplate.PTRuntimeError, message:
             return '<strong>%s</strong><ol>%s</ol>'%(message,
         except PageTemplate.PTRuntimeError, message:
             return '<strong>%s</strong><ol>%s</ol>'%(message,
-                cgi.escape('<li>'.join(pt._v_errors)))
+                '<li>'.join(pt._v_errors))
         except:
             # everything else
         except:
             # everything else
-            return cgitb.html()
+            return cgitb.pt_html()
 
     def content(self):
         ''' Callback used by the page template to render the content of 
             the page.
 
     def content(self):
         ''' Callback used by the page template to render the content of 
             the page.
+
+            If we don't have a specific class to display, that is none was
+            determined in determine_context(), then we display a "home"
+            template.
         '''
         # now render the page content using the template we determined in
         # determine_context
         '''
         # now render the page content using the template we determined in
         # determine_context
-        return self.template(self.template_name)
+        if self.classname is None:
+            name = 'home'
+        else:
+            name = self.classname
+        return self.renderTemplate(self.classname, self.template)
 
     # these are the actions that are available
     actions = {
         'edit':     'editItemAction',
 
     # these are the actions that are available
     actions = {
         'edit':     'editItemAction',
+        'editCSV':  'editCSVAction',
         'new':      'newItemAction',
         'new':      'newItemAction',
-        'login':    'login_action',
+        'register': 'registerAction',
+        'login':    'loginAction',
         'logout':   'logout_action',
         'logout':   'logout_action',
-        'register': 'register_action',
         'search':   'searchAction',
     }
     def handle_action(self):
         'search':   'searchAction',
     }
     def handle_action(self):
@@ -319,9 +362,9 @@ class Client:
             actions are defined in the "actions" dictionary on this class:
              "edit"      -> self.editItemAction
              "new"       -> self.newItemAction
             actions are defined in the "actions" dictionary on this class:
              "edit"      -> self.editItemAction
              "new"       -> self.newItemAction
-             "login"     -> self.login_action
+             "register"  -> self.registerAction
+             "login"     -> self.loginAction
              "logout"    -> self.logout_action
              "logout"    -> self.logout_action
-             "register"  -> self.register_action
              "search"    -> self.searchAction
 
         '''
              "search"    -> self.searchAction
 
         '''
@@ -337,6 +380,8 @@ class Client:
             getattr(self, self.actions[action])()
         except Redirect:
             raise
             getattr(self, self.actions[action])()
         except Redirect:
             raise
+        except Unauthorised:
+            raise
         except:
             self.db.rollback()
             s = StringIO.StringIO()
         except:
             self.db.rollback()
             s = StringIO.StringIO()
@@ -386,7 +431,7 @@ class Client:
         # generate the cookie path - make sure it has a trailing '/'
         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
             ''))
         # 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.header({'Set-Cookie': 'roundup_user_2=%s; expires=%s; Path=%s;'%(
             self.session, expire, path)})
 
     def make_user_anonymous(self):
             self.session, expire, path)})
 
     def make_user_anonymous(self):
@@ -408,7 +453,7 @@ class Client:
         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
             ''))
         self.header({'Set-Cookie':
         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,
+            'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
             path)})
         self.login()
 
             path)})
         self.login()
 
@@ -422,8 +467,11 @@ class Client:
     #
     # Actions
     #
     #
     # Actions
     #
-    def login_action(self):
-        ''' Attempt to log a user in and set the cookie
+    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'):
         '''
         # we need the username at a minimum
         if not self.form.has_key('__login_name'):
@@ -453,9 +501,23 @@ class Client:
             self.error_message.append(_('Incorrect password'))
             return
 
             self.error_message.append(_('Incorrect password'))
             return
 
+        # make sure we're allowed to be here
+        if not self.loginPermission():
+            self.make_user_anonymous()
+            raise Unauthorised, _("You do not have permission to login")
+
         # set the session cookie
         self.set_cookie(self.user, password)
 
         # set the session cookie
         self.set_cookie(self.user, password)
 
+    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
         '''
     def logout_action(self):
         ''' Make us really anonymous - nuke the cookie too
         '''
@@ -467,22 +529,30 @@ class Client:
         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
             ''))
         self.header(headers={'Set-Cookie':
         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)})
+          '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'))
 
 
         # Let the user know what's going on
         self.ok_message.append(_('You are logged out'))
 
-    def register_action(self):
+    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
         '''
         '''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
         # 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'}
+        if not self.registerPermission(props):
+            raise Unauthorised, _("You do not have permission to register")
 
         # re-open the database as "admin"
         if self.user != 'admin':
 
         # re-open the database as "admin"
         if self.user != 'admin':
@@ -493,21 +563,33 @@ class Client:
         try:
             props = parsePropsFromForm(self.db, cl, self.form)
             props['roles'] = self.instance.NEW_WEB_USER_ROLES
         try:
             props = parsePropsFromForm(self.db, cl, self.form)
             props['roles'] = self.instance.NEW_WEB_USER_ROLES
-            uid = cl.create(**props)
+            self.userid = cl.create(**props)
             self.db.commit()
         except ValueError, message:
             self.error_message.append(message)
 
         # log the new user in
             self.db.commit()
         except ValueError, message:
             self.error_message.append(message)
 
         # log the new user in
-        self.user = cl.get(uid, 'username')
+        self.user = cl.get(self.userid, 'username')
         # re-open the database for real, using the user
         self.opendb(self.user)
         # re-open the database for real, using the user
         self.opendb(self.user)
-        password = cl.get(uid, 'password')
+        password = self.db.user.get(self.userid, 'password')
         self.set_cookie(self.user, password)
 
         # nice message
         self.ok_message.append(_('You are now registered, welcome!'))
 
         self.set_cookie(self.user, password)
 
         # nice message
         self.ok_message.append(_('You are now registered, welcome!'))
 
+    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.
 
     def editItemAction(self):
         ''' Perform an edit of an item in the database.
 
@@ -524,6 +606,10 @@ class Client:
              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.
              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.
+
         '''
         cl = self.db.classes[self.classname]
 
         '''
         cl = self.db.classes[self.classname]
 
@@ -589,15 +675,15 @@ class Client:
             # if the item being edited is the current user, we're ok
             if self.nodeid == self.userid:
                 return 1
             # if the item being edited is the current user, we're ok
             if self.nodeid == self.userid:
                 return 1
-        if not self.db.security.hasPermission('Edit', self.userid,
-                self.classname):
-            return 0
-        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.
 
 
     def newItemAction(self):
         ''' Add a new item to the database.
 
-            This follows the same form as the editItemAction
+            This follows the same form as the editItemAction, with the same
+            special form values.
         '''
         cl = self.db.classes[self.classname]
 
         '''
         cl = self.db.classes[self.classname]
 
@@ -612,14 +698,17 @@ class Client:
             self.error_message.append(
                 _('You do not have permission to create %s' %self.classname))
 
             self.error_message.append(
                 _('You do not have permission to create %s' %self.classname))
 
-        # 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 = ''
+        # 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
 
         try:
             # do the create
@@ -635,7 +724,7 @@ class Client:
             self.nodeid = nid
 
             # and some nice feedback for the user
             self.nodeid = nid
 
             # and some nice feedback for the user
-            message = _('%(classname)s created ok')%self.__dict__
+            message = _('%(classname)s created ok')%self.__dict__ + xtra
         except (ValueError, KeyError), message:
             self.error_message.append(_('Error: ') + str(message))
             return
         except (ValueError, KeyError), message:
             self.error_message.append(_('Error: ') + str(message))
             return
@@ -663,19 +752,19 @@ class Client:
         if self.classname == 'user' and has('Web Registration', self.userid,
                 'user'):
             return 1
         if self.classname == 'user' and has('Web Registration', self.userid,
                 'user'):
             return 1
-        if not has('Edit', self.userid, self.classname):
-            return 0
-        return 1
+        if has('Edit', self.userid, self.classname):
+            return 1
+        return 0
 
 
-    def genericEditAction(self):
+    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.
         '''
         ''' 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.
         '''
-        # generic edit is per-class only
-        if not self.genericEditPermission():
+        # this is per-class only
+        if not self.editCSVPermission():
             self.error_message.append(
                 _('You do not have permission to edit %s' %self.classname))
 
             self.error_message.append(
                 _('You do not have permission to edit %s' %self.classname))
 
@@ -690,6 +779,7 @@ class Client:
 
         cl = self.db.classes[self.classname]
         idlessprops = cl.getprops(protected=0).keys()
 
         cl = self.db.classes[self.classname]
         idlessprops = cl.getprops(protected=0).keys()
+        idlessprops.sort()
         props = ['id'] + idlessprops
 
         # do the edit
         props = ['id'] + idlessprops
 
         # do the edit
@@ -697,19 +787,24 @@ class Client:
         p = csv.parser()
         found = {}
         line = 0
         p = csv.parser()
         found = {}
         line = 0
-        for row in rows:
+        for row in rows[1:]:
             line += 1
             values = p.parse(row)
             # not a complete row, keep going
             if not values: continue
 
             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):
             # extract the nodeid
             nodeid, values = values[0], values[1:]
             found[nodeid] = 1
 
             # confirm correct weight
             if len(idlessprops) != len(values):
-                message=(_('Not enough values on line %(line)s'%{'line':line}))
+                self.error_message.append(
+                    _('Not enough values on line %(line)s')%{'line':line})
                 return
 
             # extract the new values
                 return
 
             # extract the new values
@@ -736,13 +831,12 @@ class Client:
             if not found.has_key(nodeid):
                 cl.retire(nodeid)
 
             if not found.has_key(nodeid):
                 cl.retire(nodeid)
 
-        message = _('items edited OK')
+        # all OK
+        self.db.commit()
 
 
-        # redirect to the class' edit page
-        raise Redirect, '%s/%s?:ok_message=%s'%(self.base, self.classname, 
-            urllib.quote(message))
+        self.ok_message.append(_('Items edited OK'))
 
 
-    def genericEditPermission(self):
+    def editCSVPermission(self):
         ''' Determine whether the user has permission to edit this class.
 
             Base behaviour is to check the user can edit this class.
         ''' Determine whether the user has permission to edit this class.
 
             Base behaviour is to check the user can edit this class.
@@ -758,6 +852,9 @@ class Client:
             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.
             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():
         '''
         # generic edit is per-class only
         if not self.searchPermission():
@@ -771,6 +868,31 @@ class Client:
             if not self.form[key].value: continue
             self.form.value.append(cgi.MiniFieldStorage(':filter', key))
 
             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.
 
     def searchPermission(self):
         ''' Determine whether the user has permission to search this class.
 
@@ -781,10 +903,9 @@ class Client:
             return 0
         return 1
 
             return 0
         return 1
 
-    def XXXremove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
+    def remove_action(self,  dre=re.compile(r'([^\d]+)(\d+)')):
         # XXX I believe this could be handled by a regular edit action that
         # just sets the multilink...
         # XXX I believe this could be handled by a regular edit action that
         # just sets the multilink...
-        # XXX handle this !
         target = self.index_arg(':target')[0]
         m = dre.match(target)
         if m:
         target = self.index_arg(':target')[0]
         m = dre.match(target)
         if m:
@@ -940,36 +1061,60 @@ class Client:
 
 
 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
 
 
 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
-    '''Pull properties for the given class out of the form.
+    ''' 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.
     '''
     '''
+    required = []
+    print form.keys()
+    if form.has_key(':required'):
+        value = form[':required']
+        print 'required', value
+        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()
     for key in keys:
         if not cl.properties.has_key(key):
             continue
         proptype = cl.properties[key]
     props = {}
     keys = form.keys()
     for key in keys:
         if not cl.properties.has_key(key):
             continue
         proptype = cl.properties[key]
+
+        # 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'%key
+            # we've got a MiniFieldStorage, so pull out the value and strip
+            # surrounding whitespace
+            value = value.value.strip()
+
         if isinstance(proptype, hyperdb.String):
         if isinstance(proptype, hyperdb.String):
-            value = form[key].value.strip()
+            if not value:
+                continue
         elif isinstance(proptype, hyperdb.Password):
         elif isinstance(proptype, hyperdb.Password):
-            value = form[key].value.strip()
             if not value:
                 # ignore empty password values
                 continue
             value = password.Password(value)
         elif isinstance(proptype, hyperdb.Date):
             if not value:
                 # ignore empty password values
                 continue
             value = password.Password(value)
         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):
             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):
             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
             # see if it's the "no selection" choice
             if value == '-1':
                 value = None
@@ -984,14 +1129,13 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
                             '%(value)s not a %(classname)s')%{'propname':key, 
                             'value': value, 'classname': link}
         elif isinstance(proptype, hyperdb.Multilink):
                             '%(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(',')]
+            if isinstance(value, type([])):
+                # it's a list of MiniFieldStorages
+                value = [i.value.strip() for i in value]
             else:
             else:
-                value = [i.strip() for i in value]
+                # it's a MiniFieldStorage, but may be a comma-separated list
+                # of values
+                value = [i.strip() for i in value.value.split(',')]
             link = cl.properties[key].classname
             l = []
             for entry in map(str, value):
             link = cl.properties[key].classname
             l = []
             for entry in map(str, value):
@@ -1007,12 +1151,14 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
             l.sort()
             value = l
         elif isinstance(proptype, hyperdb.Boolean):
             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):
             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)
 
             props[key] = value = int(value)
 
+        # register this as received if required
+        if key in required:
+            required.remove(key)
+
         # get the old value
         if nodeid:
             try:
         # get the old value
         if nodeid:
             try:
@@ -1027,6 +1173,12 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
                 props[key] = value
         else:
             props[key] = value
                 props[key] = value
         else:
             props[key] = value
+
+    # see if all the required properties have been supplied
+    if required:
+        raise ValueError, 'Required properties %s not supplied'%(
+            ', '.join(required))
+
     return props
 
 
     return props