Code

support setting of properties on message and file through web and email
[roundup.git] / roundup / cgi / client.py
index dcefb3aa55208a5300be1282362f1626e781470e..170d86bc87a4cd03b0e4a35e3fcebc3e4f6cb417 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: client.py,v 1.41 2002-09-24 02:00:09 richard Exp $
+# $Id: client.py,v 1.66 2003-01-11 23:52:28 richard Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
@@ -10,7 +10,7 @@ import binascii, Cookie, time, random
 from roundup import roundupdb, date, hyperdb, password
 from roundup.i18n import _
 
-from roundup.cgi.templating import getTemplate, HTMLRequest, NoTemplate
+from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate
 from roundup.cgi import cgitb
 
 from roundup.cgi.PageTemplates import PageTemplate
@@ -48,23 +48,36 @@ def initialiseSecurity(security):
     security.addPermissionToRole('Admin', p)
 
 class Client:
-    '''
-    A note about login
-    ------------------
-
-    If the user has no login cookie, then they are anonymous. There
-    are two levels of anonymous use. If there is no 'anonymous' user, there
-    is no login at all and the database is opened in read-only mode. If the
-    'anonymous' user exists, the user is logged in using that user (though
-    there is no cookie). This allows them to modify the database, and all
-    modifications are attributed to the 'anonymous' user.
+    ''' Instantiate to handle one CGI request.
 
-    Once a user logs in, they are assigned a session. The Client instance
-    keeps the nodeid of the session as the "session" attribute.
+    See inner_main for request processing.
 
-    Client attributes:
+    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
+
+    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
+
+    User Identification:
+     If the user has no login cookie, then they are anonymous and are logged
+     in as that user. This typically gives them all Permissions assigned to the
+     Anonymous Role.
+
+     Once a user logs in, they are assigned a session. The Client instance
+     keeps the nodeid of the session as the "session" attribute.
     '''
 
     def __init__(self, instance, request, env, form=None):
@@ -134,7 +147,6 @@ class Client:
             - 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.error_message = []
         try:
@@ -152,14 +164,8 @@ class Client:
             self.additional_headers['Pragma'] = 'no-cache'
             self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
 
-            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))
+            # render the content
+            self.write(self.renderContext())
         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
@@ -173,7 +179,13 @@ class Client:
         except SendStaticFile, file:
             self.serve_static_file(str(file))
         except Unauthorised, message:
-            self.write(self.renderTemplate('page', '', error_message=message))
+            self.classname=None
+            self.template=''
+            self.error_message.append(message)
+            self.write(self.renderContext())
+        except NotFound:
+            # pass through
+            raise
         except:
             # everything else
             self.write(cgitb.html())
@@ -301,6 +313,12 @@ class Client:
             # with only a class, we default to index view
             self.template = 'index'
 
+        # make sure the classname is valid
+        try:
+            self.db.getclass(self.classname)
+        except KeyError:
+            raise NotFound, self.classname
+
         # see if we have a template override
         if self.form.has_key(':template'):
             self.template = self.form[':template'].value
@@ -333,39 +351,27 @@ class Client:
         self.write(open(os.path.join(self.instance.config.TEMPLATES,
             file)).read())
 
-    def renderTemplate(self, name, extension, **kwargs):
+    def renderContext(self):
         ''' Return a PageTemplate for the named page
         '''
-        pt = getTemplate(self.instance.config.TEMPLATES, name, extension)
+        name = self.classname
+        extension = self.template
+        pt = Templates(self.instance.config.TEMPLATES).get(name, extension)
+
         # catch errors so we can handle PT rendering errors more nicely
+        args = {
+            'ok_message': self.ok_message,
+            'error_message': self.error_message
+        }
         try:
             # let the template render figure stuff out
-            return pt.render(self, None, None, **kwargs)
-        except PageTemplate.PTRuntimeError, message:
-            return '<strong>%s</strong><ol><li>%s</ol>'%(message,
-                '<li>'.join([cgi.escape(x) for x in pt._v_errors]))
+            return pt.render(self, None, None, **args)
         except NoTemplate, message:
             return '<strong>%s</strong>'%message
         except:
             # everything else
             return cgitb.pt_html()
 
-    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
-        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'),
@@ -375,6 +381,7 @@ class Client:
         ('login',    'loginAction'),
         ('logout',   'logout_action'),
         ('search',   'searchAction'),
+        ('retire',   'retireAction'),
     )
     def handle_action(self):
         ''' Determine whether there should be an _action called.
@@ -388,7 +395,7 @@ class Client:
              "login"     -> self.loginAction
              "logout"    -> self.logout_action
              "search"    -> self.searchAction
-
+             "retire"    -> self.retireAction
         '''
         if not self.form.has_key(':action'):
             return None
@@ -439,7 +446,10 @@ class Client:
         if self.debug:
             self.headers_sent = headers
 
-    def set_cookie(self, user, password):
+    def set_cookie(self, user):
+        ''' Set up a session cookie for the user and 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()
 
@@ -497,25 +507,24 @@ class Client:
             self.error_message.append(_('Username required'))
             return
 
+        # get the login info
         self.user = self.form['__login_name'].value
-        # re-open the database for real, using the user
-        self.opendb(self.user)
         if self.form.has_key('__login_password'):
             password = self.form['__login_password'].value
         else:
             password = ''
+
         # make sure the user exists
         try:
             self.userid = self.db.user.lookup(self.user)
         except KeyError:
             name = self.user
-            self.make_user_anonymous()
             self.error_message.append(_('No such user "%(name)s"')%locals())
+            self.make_user_anonymous()
             return
 
-        # and that the password is correct
-        pw = self.db.user.get(self.userid, 'password')
-        if password != pw:
+        # verify the password
+        if not self.verifyPassword(self.userid, password):
             self.make_user_anonymous()
             self.error_message.append(_('Incorrect password'))
             return
@@ -523,10 +532,24 @@ class Client:
         # 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")
+            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, password)
+        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.
@@ -584,7 +607,7 @@ class Client:
             props['roles'] = self.instance.config.NEW_WEB_USER_ROLES
             self.userid = cl.create(**props)
             self.db.commit()
-        except ValueError, message:
+        except (ValueError, KeyError), message:
             self.error_message.append(message)
             return
 
@@ -592,8 +615,14 @@ class Client:
         self.user = cl.get(self.userid, 'username')
         # re-open the database for real, using the user
         self.opendb(self.user)
-        password = self.db.user.get(self.userid, 'password')
-        self.set_cookie(self.user, password)
+
+        # 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!')
@@ -634,6 +663,11 @@ class Client:
             :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]
 
@@ -657,7 +691,7 @@ class Client:
             props = self._changenode(props)
             # handle linked nodes 
             self._post_editnode(self.nodeid)
-        except (ValueError, KeyError), message:
+        except (ValueError, KeyError, IndexError), message:
             self.error_message.append(_('Error: ') + str(message))
             return
 
@@ -737,7 +771,19 @@ class Client:
         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
 
+        try:
             # handle linked nodes 
             self._post_editnode(nid)
 
@@ -749,9 +795,6 @@ class Client:
 
             # and some nice feedback for the user
             message = _('%(classname)s created ok')%self.__dict__ + xtra
-        except (ValueError, KeyError), message:
-            self.error_message.append(_('Error: ') + str(message))
-            return
         except:
             # oops
             self.db.rollback()
@@ -927,33 +970,46 @@ class Client:
             return 0
         return 1
 
-    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...
-        target = self.index_arg(':target')[0]
-        m = dre.match(target)
-        if m:
-            classname = m.group(1)
-            nodeid = m.group(2)
-            cl = self.db.getclass(classname)
-            cl.retire(nodeid)
-            # now take care of the reference
-            parentref =  self.index_arg(':multilink')[0]
-            parent, prop = parentref.split(':')
-            m = dre.match(parent)
-            if m:
-                self.classname = m.group(1)
-                self.nodeid = m.group(2)
-                cl = self.db.getclass(self.classname)
-                value = cl.get(self.nodeid, prop)
-                value.remove(nodeid)
-                cl.set(self.nodeid, **{prop:value})
-                func = getattr(self, 'show%s'%self.classname)
-                return func()
-            else:
-                raise NotFound, parent
-        else:
-            raise NotFound, target
+    def 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
@@ -994,14 +1050,28 @@ class Client:
         files = []
         if self.form.has_key(':file'):
             file = self.form[':file']
+
+            # if there's a filename, then we create a file
             if file.filename:
+                # see if there are any file properties we should set
+                file_props={};
+                if self.form.has_key(':file_fields'):
+                    for field in self.form[':file_fields'].value.split(','):
+                        if self.form.has_key(field):
+                            if field.startswith("file_"):
+                                file_props[field[5:]] = self.form[field].value
+                            else :
+                                file_props[field] = self.form[field].value
+
+                # try to determine the file content-type
                 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()))
+                    name=filename, content=file.file.read(), **file_props))
 
         # we don't want to do a message if none of the following is true...
         cn = self.classname
@@ -1011,7 +1081,8 @@ class Client:
         # in a nutshell, don't do anything if there's no note or there's no
         # NOSY
         if self.form.has_key(':note'):
-            note = self.form[':note'].value.strip()
+            # 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'):
@@ -1035,11 +1106,21 @@ class Client:
         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
             self.classname, self.instance.config.MAIL_DOMAIN)
 
+        # see if there are any message properties we should set
+        msg_props={};
+        if self.form.has_key(':msg_fields'):
+            for field in self.form[':msg_fields'].value.split(','):
+                if self.form.has_key(field):
+                    if field.startswith("msg_"):
+                        msg_props[field[4:]] = self.form[field].value
+                    else :
+                        msg_props[field] = self.form[field].value
+
         # 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)
+            content=content, files=files, messageid=messageid, **msg_props)
 
         # update the messages property
         return message_id, files
@@ -1083,12 +1164,27 @@ class Client:
                     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'):
@@ -1101,20 +1197,46 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
     props = {}
     keys = form.keys()
     properties = cl.getprops()
+    existing_cache = {}
     for key in keys:
-        if not properties.has_key(key):
+        # 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 == 'remove':
+                raise ValueError, 'You have submitted a remove action for'\
+                    ' the property "%s" which doesn\'t exist'%propname
             continue
-        proptype = properties[key]
+        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):
+        # handle unpacking of the MiniFieldStorage / list form value
+        if isinstance(proptype, hyperdb.Multilink):
+            # multiple values are OK
+            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(',')]
+        else:
+            # multiple values are not OK
             if isinstance(value, type([])):
                 raise ValueError, 'You have submitted more than one value'\
-                    ' for the %s property'%key
+                    ' for the %s property'%propname
             # we've got a MiniFieldStorage, so pull out the value and strip
             # surrounding whitespace
             value = value.value.strip()
@@ -1122,95 +1244,126 @@ def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
         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'%key):
+            if not form.has_key('%s:confirm'%propname):
                 raise ValueError, 'Password and confirmation text do not match'
-            confirm = form['%s:confirm'%key]
+            confirm = form['%s:confirm'%propname]
             if isinstance(confirm, type([])):
                 raise ValueError, 'You have submitted more than one value'\
-                    ' for the %s property'%key
+                    ' 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(form[key].value.strip())
+                value = date.Date(value)
             else:
-                continue
+                value = None
         elif isinstance(proptype, hyperdb.Interval):
             if value:
-                value = date.Interval(form[key].value.strip())
+                value = date.Interval(value)
             else:
-                continue
+                value = None
         elif isinstance(proptype, hyperdb.Link):
             # see if it's the "no selection" choice
             if value == '-1':
-                continue
-            # 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':key, 
-                        'value': value, 'classname': link}
-                except TypeError, message:
-                    raise ValueError, _('you may only enter ID values '
-                        'for property "%(propname)s": %(message)s')%{
-                        'propname':key, '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]
+                # if we're creating, just don't include this property
+                if not nodeid:
+                    continue
+                value = None
             else:
-                # it's a MiniFieldStorage, but may be a comma-separated list
-                # of values
-                value = [i.strip() for i in value.value.split(',')]
+                # 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):
+            # perform link class key value lookup if necessary
             link = proptype.classname
             l = []
-            for entry in map(str, value):
-                if entry == '': continue
+            for entry in value:
+                if not entry: continue
                 if not num_re.match(entry):
                     try:
                         entry = db.classes[link].lookup(entry)
                     except KeyError:
                         raise ValueError, _('property "%(propname)s": '
                             '"%(value)s" not an entry of %(classname)s')%{
-                            'propname':key, 'value': entry, 'classname': link}
+                            'propname': propname, 'value': entry,
+                            'classname': link}
                     except TypeError, message:
                         raise ValueError, _('you may only enter ID values '
                             'for property "%(propname)s": %(message)s')%{
-                            'propname':key, 'message': message}
+                            'propname': propname, 'message': message}
                 l.append(entry)
             l.sort()
-            value = l
+
+            # 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
+                if props.has_key(propname):
+                    existing = props[propname]
+                else:
+                    existing = cl.get(nodeid, propname, [])
+
+                # now either remove or add
+                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):
-            props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
+            value = value.lower() in ('yes', 'true', 'on', '1')
         elif isinstance(proptype, hyperdb.Number):
-            props[key] = value = int(value)
+            value = int(value)
 
-        # register this as received if required
-        if key in required:
-            required.remove(key)
+        # 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, key)
+                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(key): raise
+                if not properties.has_key(propname):
+                    raise
 
             # if changed, set it
             if value != existing:
-                props[key] = value
+                props[propname] = value
         else:
-            props[key] = value
+            props[propname] = value
 
     # see if all the required properties have been supplied
     if required: