Code

much nicer error messages when there's a templating error
[roundup.git] / roundup / cgi / client.py
index 9c786f99376c60c31ab7bb438c4ea9d493024829..122c0bc07a936cbfe03d271bd0155e5a94ea4b3d 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: client.py,v 1.8 2002-09-03 03:23:56 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).
@@ -100,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 = []
@@ -110,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
@@ -125,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())
@@ -187,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
@@ -219,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]
@@ -239,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
@@ -255,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.
         '''
@@ -279,10 +313,10 @@ class Client:
         self.header({'Content-Type': mt})
         self.write(open(os.path.join(self.instance.TEMPLATES, file)).read())
 
         self.header({'Content-Type': mt})
         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 = getTemplate(self.instance.TEMPLATES, name)
+        pt = getTemplate(self.instance.TEMPLATES, name, extension)
         # XXX handle PT rendering errors here more nicely
         try:
             # let the template render figure stuff out
         # XXX handle PT rendering errors here more nicely
         try:
             # let the template render figure stuff out
@@ -292,22 +326,31 @@ class Client:
                 '<li>'.join(pt._v_errors))
         except:
             # everything else
                 '<li>'.join(pt._v_errors))
         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',
         'register': 'registerAction',
         'new':      'newItemAction',
         'register': 'registerAction',
-        'login':    'login_action',
+        'login':    'loginAction',
         'logout':   'logout_action',
         'search':   'searchAction',
     }
         'logout':   'logout_action',
         'search':   'searchAction',
     }
@@ -320,7 +363,7 @@ class Client:
              "edit"      -> self.editItemAction
              "new"       -> self.newItemAction
              "register"  -> self.registerAction
              "edit"      -> self.editItemAction
              "new"       -> self.newItemAction
              "register"  -> self.registerAction
-             "login"     -> self.login_action
+             "login"     -> self.loginAction
              "logout"    -> self.logout_action
              "search"    -> self.searchAction
 
              "logout"    -> self.logout_action
              "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()
@@ -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
         '''
@@ -544,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]
 
@@ -616,7 +682,8 @@ class Client:
     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]
 
@@ -631,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
@@ -654,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
@@ -686,15 +756,15 @@ class Client:
             return 1
         return 0
 
             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))
 
@@ -709,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
@@ -716,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
@@ -755,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.
@@ -818,7 +893,6 @@ class Client:
                 # commit the query change to the database
                 self.db.commit()
 
                 # 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.
 
@@ -829,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:
@@ -988,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
@@ -1032,11 +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 not isinstance(value, type([])):
-                value = [i.strip() for i in value.split(',')]
-            else:
+            if isinstance(value, type([])):
+                # it's a list of MiniFieldStorages
                 value = [i.value.strip() for i in value]
                 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 = cl.properties[key].classname
             l = []
             for entry in map(str, value):
             link = cl.properties[key].classname
             l = []
             for entry in map(str, value):
@@ -1052,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:
@@ -1072,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