Code

Move out parts of client.py to new modules:
authorjlgijsbers <jlgijsbers@57a73879-2fb5-44c3-a270-3262357dd7e2>
Wed, 11 Feb 2004 21:34:31 +0000 (21:34 +0000)
committerjlgijsbers <jlgijsbers@57a73879-2fb5-44c3-a270-3262357dd7e2>
Wed, 11 Feb 2004 21:34:31 +0000 (21:34 +0000)
* actions.py - the xxxAction and xxxPermission functions refactored into Action classes
* exceptions.py - all exceptions
* form_parser.py - parsePropsFromForm & extractFormList in a FormParser class

Also added some new tests for the Actions.

git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@2072 57a73879-2fb5-44c3-a270-3262357dd7e2

roundup/cgi/actions.py [new file with mode: 0755]
roundup/cgi/client.py
roundup/cgi/exceptions.py [new file with mode: 0755]
roundup/cgi/form_parser.py [new file with mode: 0755]
test/test_actions.py [new file with mode: 0755]
test/test_cgi.py

diff --git a/roundup/cgi/actions.py b/roundup/cgi/actions.py
new file mode 100755 (executable)
index 0000000..994a96f
--- /dev/null
@@ -0,0 +1,745 @@
+import re, cgi, StringIO, urllib, Cookie, time, random
+
+from roundup import hyperdb, token, date, password, rcsv
+from roundup.i18n import _
+from roundup.cgi import templating
+from roundup.cgi.exceptions import Redirect, Unauthorised
+from roundup.mailgw import uidFromAddress
+
+__all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
+           'EditCSVAction', 'EditItemAction', 'PassResetAction',
+           'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction']
+
+# used by a couple of routines
+chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+
+class Action:
+    def __init__(self, client):
+        self.client = client
+        self.form = client.form
+        self.db = client.db
+        self.nodeid = client.nodeid
+        self.template = client.template
+        self.classname = client.classname
+        self.userid = client.userid
+        self.base = client.base
+        self.user = client.user
+        
+    def handle(self):
+        """Execute the action specified by this object."""
+        raise NotImplementedError
+
+    def permission(self):
+        """Check whether the user has permission to execute this action.
+
+        True by default.
+        """
+        return 1
+
+class ShowAction(Action):
+    def handle(self, typere=re.compile('[@:]type'),
+               numre=re.compile('[@:]number')):
+        """Show a node of a particular class/id."""
+        t = n = ''
+        for key in self.form.keys():
+            if typere.match(key):
+                t = self.form[key].value.strip()
+            elif numre.match(key):
+                n = self.form[key].value.strip()
+        if not t:
+            raise ValueError, 'Invalid %s number'%t
+        url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
+        raise Redirect, url
+
+class RetireAction(Action):
+    def handle(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.client.nodeid = None
+
+        # generic edit is per-class only
+        if not self.permission():
+            raise Unauthorised, _('You do not have permission to retire %s' %
+                                  self.classname)
+
+        # 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'):
+            raise ValueError, _('You may not retire the admin or anonymous user')
+
+        # do the retire
+        self.db.getclass(self.classname).retire(nodeid)
+        self.db.commit()
+
+        self.client.ok_message.append(
+            _('%(classname)s %(itemid)s has been retired')%{
+                'classname': self.classname.capitalize(), 'itemid': nodeid})
+
+    def permission(self):
+        """Determine whether the user has permission to retire this class.
+
+        Base behaviour is to check the user can edit this class.
+        """ 
+        return self.db.security.hasPermission('Edit', self.client.userid,
+                                              self.client.classname)
+
+class SearchAction(Action):
+    def handle(self, wcre=re.compile(r'[\s,]+')):
+        """Mangle some of the form variables.
+
+        Set the form ":filter" variable based on the values of the filter
+        variables - if they're set to anything other than "dontcare" then add
+        them to :filter.
+
+        Handle the ":queryname" variable and save off the query to the user's
+        query list.
+
+        Split any String query values on whitespace and comma.
+
+        """
+        # generic edit is per-class only
+        if not self.permission():
+            raise Unauthorised, _('You do not have permission to search %s' %
+                                  self.classname)
+
+        self.fakeFilterVars()
+        queryname = self.getQueryName()        
+
+        # handle saving the query params
+        if queryname:
+            # parse the environment and figure what the query _is_
+            req = templating.HTMLRequest(self.client)
+
+            # The [1:] strips off the '?' character, it isn't part of the
+            # query string.
+            url = req.indexargs_href('', {})[1:]
+
+            # 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 fakeFilterVars(self):
+        """Add a faked :filter form variable for each filtering prop."""
+        props = self.db.classes[self.classname].getprops()
+        for key in self.form.keys():
+            if not props.has_key(key):
+                continue
+            if isinstance(self.form[key], type([])):
+                # search for at least one entry which is not empty
+                for minifield in self.form[key]:
+                    if minifield.value:
+                        break
+                else:
+                    continue
+            else:
+                if not self.form[key].value:
+                    continue
+                if isinstance(props[key], hyperdb.String):
+                    v = self.form[key].value
+                    l = token.token_split(v)
+                    if len(l) > 1 or l[0] != v:
+                        self.form.value.remove(self.form[key])
+                        # replace the single value with the split list
+                        for v in l:
+                            self.form.value.append(cgi.MiniFieldStorage(key, v))
+        
+            self.form.value.append(cgi.MiniFieldStorage('@filter', key))
+
+    FV_QUERYNAME = re.compile(r'[@:]queryname')
+    def getQueryName(self):
+        for key in self.form.keys():
+            if self.FV_QUERYNAME.match(key):
+                return self.form[key].value.strip()
+        return ''
+        
+    def permission(self):
+        return self.db.security.hasPermission('View', self.client.userid,
+                                              self.client.classname)
+
+class EditCSVAction(Action):
+    def handle(self):
+        """Performs an edit of all of a class' items in one go.
+
+        The "rows" CGI var defines the CSV-formatted entries for the class. New
+        nodes are identified by the ID 'X' (or any other non-existent ID) and
+        removed lines are retired.
+
+        """
+        # this is per-class only
+        if not self.permission():
+            self.client.error_message.append(
+                 _('You do not have permission to edit %s' %self.classname))
+            return
+
+        # get the CSV module
+        if rcsv.error:
+            self.client.error_message.append(_(rcsv.error))
+            return
+
+        cl = self.db.classes[self.classname]
+        idlessprops = cl.getprops(protected=0).keys()
+        idlessprops.sort()
+        props = ['id'] + idlessprops
+
+        # do the edit
+        rows = StringIO.StringIO(self.form['rows'].value)
+        reader = rcsv.reader(rows, rcsv.comma_separated)
+        found = {}
+        line = 0
+        for values in reader:
+            line += 1
+            if line == 1: continue
+            # skip property names header
+            if values == props:
+                continue
+
+            # extract the nodeid
+            nodeid, values = values[0], values[1:]
+            found[nodeid] = 1
+
+            # see if the node exists
+            if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
+                exists = 0
+            else:
+                exists = 1
+
+            # confirm correct weight
+            if len(idlessprops) != len(values):
+                self.client.error_message.append(
+                    _('Not enough values on line %(line)s')%{'line':line})
+                return
+
+            # extract the new values
+            d = {}
+            for name, value in zip(idlessprops, values):
+                prop = cl.properties[name]
+                value = value.strip()
+                # only add the property if it has a value
+                if value:
+                    # if it's a multilink, split it
+                    if isinstance(prop, hyperdb.Multilink):
+                        value = value.split(':')
+                    elif isinstance(prop, hyperdb.Password):
+                        value = password.Password(value)
+                    elif isinstance(prop, hyperdb.Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, hyperdb.Date):
+                        value = date.Date(value)
+                    elif isinstance(prop, hyperdb.Boolean):
+                        value = value.lower() in ('yes', 'true', 'on', '1')
+                    elif isinstance(prop, hyperdb.Number):
+                        value = float(value)
+                    d[name] = value
+                elif exists:
+                    # nuke the existing value
+                    if isinstance(prop, hyperdb.Multilink):
+                        d[name] = []
+                    else:
+                        d[name] = None
+
+            # perform the edit
+            if exists:
+                # edit existing
+                cl.set(nodeid, **d)
+            else:
+                # new node
+                found[cl.create(**d)] = 1
+
+        # retire the removed entries
+        for nodeid in cl.list():
+            if not found.has_key(nodeid):
+                cl.retire(nodeid)
+
+        # all OK
+        self.db.commit()
+
+        self.client.ok_message.append(_('Items edited OK'))
+
+    def permission(self):
+        return self.db.security.hasPermission('Edit', self.client.userid,
+                                              self.client.classname)
+
+class EditItemAction(Action):
+    def handle(self):
+        """Perform an edit of an item in the database.
+
+        See parsePropsFromForm and _editnodes for special variables.
+        
+        """
+        props, links = self.client.parsePropsFromForm()
+
+        # handle the props
+        try:
+            message = self._editnodes(props, links)
+        except (ValueError, KeyError, IndexError), message:
+            self.client.error_message.append(_('Apply Error: ') + str(message))
+            return
+
+        # commit now that all the tricky stuff is done
+        self.db.commit()
+
+        # redirect to the item's edit page
+        raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
+                                                              self.classname, self.client.nodeid,
+                                                              urllib.quote(message),
+                                                              urllib.quote(self.template))
+    
+    def editItemPermission(self, props):
+        """Determine whether the user has permission to edit this item.
+
+        Base behaviour is to check the user can edit this class. If we're
+        editing the"user" class, users are allowed to edit their own details.
+        Unless it's the "roles" property, which requires the special Permission
+        "Web Roles".
+        """
+        # if this is a user node and the user is editing their own node, then
+        # we're OK
+        has = self.db.security.hasPermission
+        if self.classname == 'user':
+            # reject if someone's trying to edit "roles" and doesn't have the
+            # right permission.
+            if props.has_key('roles') and not has('Web Roles', self.userid,
+                    'user'):
+                return 0
+            # if the item being edited is the current user, we're ok
+            if (self.nodeid == self.userid
+                and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
+                return 1
+        if self.db.security.hasPermission('Edit', self.userid, self.classname):
+            return 1
+        return 0
+
+    def newItemPermission(self, props):
+        """Determine whether the user has permission to create (edit) this item.
+
+        Base behaviour is to check the user can edit this class. No additional
+        property checks are made. Additionally, new user items may be created
+        if the user has the "Web Registration" Permission.
+
+        """
+        has = self.db.security.hasPermission
+        if self.classname == 'user' and has('Web Registration', self.userid,
+                'user'):
+            return 1
+        if has('Edit', self.userid, self.classname):
+            return 1
+        return 0
+
+    #
+    #  Utility methods for editing
+    #
+    def _editnodes(self, all_props, all_links, newids=None):
+        ''' Use the props in all_props to perform edit and creation, then
+            use the link specs in all_links to do linking.
+        '''
+        # figure dependencies and re-work links
+        deps = {}
+        links = {}
+        for cn, nodeid, propname, vlist in all_links:
+            if not all_props.has_key((cn, nodeid)):
+                # link item to link to doesn't (and won't) exist
+                continue
+            for value in vlist:
+                if not all_props.has_key(value):
+                    # link item to link to doesn't (and won't) exist
+                    continue
+                deps.setdefault((cn, nodeid), []).append(value)
+                links.setdefault(value, []).append((cn, nodeid, propname))
+
+        # figure chained dependencies ordering
+        order = []
+        done = {}
+        # loop detection
+        change = 0
+        while len(all_props) != len(done):
+            for needed in all_props.keys():
+                if done.has_key(needed):
+                    continue
+                tlist = deps.get(needed, [])
+                for target in tlist:
+                    if not done.has_key(target):
+                        break
+                else:
+                    done[needed] = 1
+                    order.append(needed)
+                    change = 1
+            if not change:
+                raise ValueError, 'linking must not loop!'
+
+        # now, edit / create
+        m = []
+        for needed in order:
+            props = all_props[needed]
+            if not props:
+                # nothing to do
+                continue
+            cn, nodeid = needed
+
+            if nodeid is not None and int(nodeid) > 0:
+                # make changes to the node
+                props = self._changenode(cn, nodeid, props)
+
+                # and some nice feedback for the user
+                if props:
+                    info = ', '.join(props.keys())
+                    m.append('%s %s %s edited ok'%(cn, nodeid, info))
+                else:
+                    m.append('%s %s - nothing changed'%(cn, nodeid))
+            else:
+                assert props
+
+                # make a new node
+                newid = self._createnode(cn, props)
+                if nodeid is None:
+                    self.client.nodeid = newid
+                nodeid = newid
+
+                # and some nice feedback for the user
+                m.append('%s %s created'%(cn, newid))
+
+            # fill in new ids in links
+            if links.has_key(needed):
+                for linkcn, linkid, linkprop in links[needed]:
+                    props = all_props[(linkcn, linkid)]
+                    cl = self.db.classes[linkcn]
+                    propdef = cl.getprops()[linkprop]
+                    if not props.has_key(linkprop):
+                        if linkid is None or linkid.startswith('-'):
+                            # linking to a new item
+                            if isinstance(propdef, hyperdb.Multilink):
+                                props[linkprop] = [newid]
+                            else:
+                                props[linkprop] = newid
+                        else:
+                            # linking to an existing item
+                            if isinstance(propdef, hyperdb.Multilink):
+                                existing = cl.get(linkid, linkprop)[:]
+                                existing.append(nodeid)
+                                props[linkprop] = existing
+                            else:
+                                props[linkprop] = newid
+
+        return '<br>'.join(m)
+
+    def _changenode(self, cn, nodeid, props):
+        """Change the node based on the contents of the form."""
+        # check for permission
+        if not self.editItemPermission(props):
+            raise Unauthorised, 'You do not have permission to edit %s'%cn
+
+        # make the changes
+        cl = self.db.classes[cn]
+        return cl.set(nodeid, **props)
+
+    def _createnode(self, cn, props):
+        """Create a node based on the contents of the form."""
+        # check for permission
+        if not self.newItemPermission(props):
+            raise Unauthorised, 'You do not have permission to create %s'%cn
+
+        # create the node and return its id
+        cl = self.db.classes[cn]
+        return cl.create(**props)
+        
+class PassResetAction(Action):
+    def handle(self):
+        """Handle password reset requests.
+    
+        Presence of either "name" or "address" generates email. Presence of
+        "otk" performs the reset.
+    
+        """
+        if self.form.has_key('otk'):
+            # pull the rego information out of the otk database
+            otk = self.form['otk'].value
+            uid = self.db.otks.get(otk, 'uid')
+            if uid is None:
+                self.client.error_message.append("""Invalid One Time Key!
+(a Mozilla bug may cause this message to show up erroneously,
+ please check your email)""")
+                return
+
+            # re-open the database as "admin"
+            if self.user != 'admin':
+                self.client.opendb('admin')
+                self.db = self.client.db
+
+            # change the password
+            newpw = password.generatePassword()
+
+            cl = self.db.user
+# XXX we need to make the "default" page be able to display errors!
+            try:
+                # set the password
+                cl.set(uid, password=password.Password(newpw))
+                # clear the props from the otk database
+                self.db.otks.destroy(otk)
+                self.db.commit()
+            except (ValueError, KeyError), message:
+                self.client.error_message.append(str(message))
+                return
+
+            # user info
+            address = self.db.user.get(uid, 'address')
+            name = self.db.user.get(uid, 'username')
+
+            # send the email
+            tracker_name = self.db.config.TRACKER_NAME
+            subject = 'Password reset for %s'%tracker_name
+            body = '''
+The password has been reset for username "%(name)s".
+
+Your password is now: %(password)s
+'''%{'name': name, 'password': newpw}
+            if not self.client.standard_message([address], subject, body):
+                return
+
+            self.client.ok_message.append('Password reset and email sent to %s' %
+                                          address)
+            return
+
+        # no OTK, so now figure the user
+        if self.form.has_key('username'):
+            name = self.form['username'].value
+            try:
+                uid = self.db.user.lookup(name)
+            except KeyError:
+                self.client.error_message.append('Unknown username')
+                return
+            address = self.db.user.get(uid, 'address')
+        elif self.form.has_key('address'):
+            address = self.form['address'].value
+            uid = uidFromAddress(self.db, ('', address), create=0)
+            if not uid:
+                self.client.error_message.append('Unknown email address')
+                return
+            name = self.db.user.get(uid, 'username')
+        else:
+            self.client.error_message.append('You need to specify a username '
+                'or address')
+            return
+
+        # generate the one-time-key and store the props for later
+        otk = ''.join([random.choice(chars) for x in range(32)])
+        self.db.otks.set(otk, uid=uid, __time=time.time())
+
+        # send the email
+        tracker_name = self.db.config.TRACKER_NAME
+        subject = 'Confirm reset of password for %s'%tracker_name
+        body = '''
+Someone, perhaps you, has requested that the password be changed for your
+username, "%(name)s". If you wish to proceed with the change, please follow
+the link below:
+
+  %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
+
+You should then receive another email with the new password.
+'''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
+        if not self.client.standard_message([address], subject, body):
+            return
+
+        self.client.ok_message.append('Email sent to %s'%address)
+
+class ConfRegoAction(Action):
+    def handle(self):
+        """Grab the OTK, use it to load up the new user details."""
+        try:
+            # pull the rego information out of the otk database
+            self.userid = self.db.confirm_registration(self.form['otk'].value)
+        except (ValueError, KeyError), message:
+            # XXX: we need to make the "default" page be able to display errors!
+            self.client.error_message.append(str(message))
+            return
+        
+        # log the new user in
+        self.client.user = self.db.user.get(self.userid, 'username')
+        # re-open the database for real, using the user
+        self.client.opendb(self.client.user)
+        self.db = client.db
+
+        # 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.client.set_cookie(self.user)
+
+        # nice message
+        message = _('You are now registered, welcome!')
+
+        # redirect to the user's page
+        raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
+                                                   self.userid, urllib.quote(message))
+
+class RegisterAction(Action):
+    def handle(self):
+        """Attempt to create a new user based on the contents of the form
+        and then set the cookie.
+
+        Return 1 on successful login.
+        """
+        props = self.client.parsePropsFromForm()[0][('user', None)]
+
+        # make sure we're allowed to register
+        if not self.permission(props):
+            raise Unauthorised, _("You do not have permission to register")
+
+        try:
+            self.db.user.lookup(props['username'])
+            self.client.error_message.append('Error: A user with the username "%s" '
+                'already exists'%props['username'])
+            return
+        except KeyError:
+            pass
+
+        # generate the one-time-key and store the props for later
+        otk = ''.join([random.choice(chars) for x in range(32)])
+        for propname, proptype in self.db.user.getprops().items():
+            value = props.get(propname, None)
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                props[propname] = str(value)
+            elif isinstance(proptype, hyperdb.Interval):
+                props[propname] = str(value)
+            elif isinstance(proptype, hyperdb.Password):
+                props[propname] = str(value)
+        props['__time'] = time.time()
+        self.db.otks.set(otk, **props)
+
+        # send the email
+        tracker_name = self.db.config.TRACKER_NAME
+        tracker_email = self.db.config.TRACKER_EMAIL
+        subject = 'Complete your registration to %s -- key %s' % (tracker_name,
+                                                                  otk)
+        body = """To complete your registration of the user "%(name)s" with
+%(tracker)s, please do one of the following:
+
+- send a reply to %(tracker_email)s and maintain the subject line as is (the
+reply's additional "Re:" is ok),
+
+- or visit the following URL:
+
+%(url)s?@action=confrego&otk=%(otk)s
+""" % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
+        'otk': otk, 'tracker_email': tracker_email}
+        if not self.client.standard_message([props['address']], subject, body,
+        tracker_email):
+            return
+
+        # commit changes to the database
+        self.db.commit()
+
+        # redirect to the "you're almost there" page
+        raise Redirect, '%suser?@template=rego_progress'%self.base
+
+    def permission(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
+
+class LogoutAction(Action):
+    def handle(self):
+        """Make us really anonymous - nuke the cookie too."""
+        # log us out
+        self.client.make_user_anonymous()
+
+        # construct the logout cookie
+        now = Cookie._getdate()
+        self.client.additional_headers['Set-Cookie'] = \
+           '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
+            now, self.client.cookie_path)
+
+        # Let the user know what's going on
+        self.client.ok_message.append(_('You are logged out'))
+
+class LoginAction(Action):
+    def handle(self):
+        """Attempt to log a user in.
+
+        Sets up a session for the user which contains the login credentials.
+
+        """
+        # we need the username at a minimum
+        if not self.form.has_key('__login_name'):
+            self.client.error_message.append(_('Username required'))
+            return
+
+        # get the login info
+        self.client.user = self.form['__login_name'].value
+        if self.form.has_key('__login_password'):
+            password = self.form['__login_password'].value
+        else:
+            password = ''
+
+        # make sure the user exists
+        try:
+            self.client.userid = self.db.user.lookup(self.client.user)
+        except KeyError:
+            name = self.client.user
+            self.client.error_message.append(_('No such user "%(name)s"')%locals())
+            self.client.make_user_anonymous()
+            return
+
+        # verify the password
+        if not self.verifyPassword(self.client.userid, password):
+            self.client.make_user_anonymous()
+            self.client.error_message.append(_('Incorrect password'))
+            return
+
+        # make sure we're allowed to be here
+        if not self.permission():
+            self.client.make_user_anonymous()
+            self.client.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.client.opendb(self.client.user)
+
+        # set the session cookie
+        self.client.set_cookie(self.client.user)
+
+    def verifyPassword(self, userid, password):
+        ''' Verify the password that the user has supplied
+        '''
+        stored = self.db.user.get(self.client.userid, 'password')
+        if password == stored:
+            return 1
+        if not password and not stored:
+            return 1
+        return 0
+
+    def permission(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.client.userid):
+            return 0
+        return 1
index 672f89918b5b96777dced1e9581872d95411f96b..ace158e16abd92625c7a77f817ebb7987e8a11ef 100644 (file)
@@ -1,51 +1,20 @@
-# $Id: client.py,v 1.154 2004-01-20 05:55:24 richard Exp $
+# $Id: client.py,v 1.155 2004-02-11 21:34:31 jlgijsbers Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
 """
 
 import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
-import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri
-import stat, rfc822
+import binascii, Cookie, time, random, stat, rfc822
 
-from roundup import roundupdb, date, hyperdb, password, token, rcsv
+from roundup import roundupdb, date, hyperdb, password
 from roundup.i18n import _
 from roundup.cgi import templating, cgitb
-from roundup.cgi.PageTemplates import PageTemplate
-from roundup.rfc2822 import encode_header
-from roundup.mailgw import uidFromAddress
+from roundup.cgi.actions import *
+from roundup.cgi.exceptions import *
+from roundup.cgi.form_parser import FormParser
 from roundup.mailer import Mailer, MessageSendError
 
-class HTTPException(Exception):
-    pass
-class Unauthorised(HTTPException):
-    pass
-class NotFound(HTTPException):
-    pass
-class Redirect(HTTPException):
-    pass
-class NotModified(HTTPException):
-    pass
-
-# used by a couple of routines
-chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
-
-class FormError(ValueError):
-    """ An "expected" exception occurred during form parsing.
-        - ie. something we know can go wrong, and don't want to alarm the
-          user with
-
-        We trap this at the user interface level and feed back a nice error
-        to the user.
-    """
-    pass
-
-class SendFile(Exception):
-    ''' Send a file from the database '''
-
-class SendStaticFile(Exception):
-    ''' Send a static file from the instance html directory '''
-
 def initialiseSecurity(security):
     ''' Create some Permissions and Roles on the security object
 
@@ -122,30 +91,6 @@ class Client:
     FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
     FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
 
-    FV_QUERYNAME = re.compile(r'[@:]queryname')
-
-    # edit form variable handling (see unit tests)
-    FV_LABELS = r'''
-       ^(
-         (?P<note>[@:]note)|
-         (?P<file>[@:]file)|
-         (
-          ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
-          ((?P<required>[@:]required$)|       # :required
-           (
-            (
-             (?P<add>[@:]add[@:])|            # :add:<prop>
-             (?P<remove>[@:]remove[@:])|      # :remove:<prop>
-             (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
-             (?P<link>[@:]link[@:])|          # :link:<prop>
-             ([@:])                           # just a separator
-            )?
-            (?P<propname>[^@:]+)             # <prop>
-           )
-          )
-         )
-        )$'''
-
     # Note: index page stuff doesn't appear here:
     # columns, sort, sortdir, filter, group, groupdir, search_text,
     # pagesize, startwith
@@ -565,17 +510,17 @@ class Client:
 
     # these are the actions that are available
     actions = (
-        ('edit',     'editItemAction'),
-        ('editcsv',  'editCSVAction'),
-        ('new',      'newItemAction'),
-        ('register', 'registerAction'),
-        ('confrego', 'confRegoAction'),
-        ('passrst',  'passResetAction'),
-        ('login',    'loginAction'),
-        ('logout',   'logout_action'),
-        ('search',   'searchAction'),
-        ('retire',   'retireAction'),
-        ('show',     'showAction'),
+        ('edit',     EditItemAction),
+        ('editcsv',  EditCSVAction),
+        ('new',      EditItemAction),
+        ('register', RegisterAction),
+        ('confrego', ConfRegoAction),
+        ('passrst',  PassResetAction),
+        ('login',    LoginAction),
+        ('logout',   LogoutAction),
+        ('search',   SearchAction),
+        ('retire',   RetireAction),
+        ('show',     ShowAction),
     )
     def handle_action(self):
         ''' Determine whether there should be an Action called.
@@ -592,17 +537,15 @@ class Client:
             return None
         try:
             # get the action, validate it
-            for name, method in self.actions:
+            for name, action_klass in self.actions:
                 if name == action:
                     break
             else:
                 raise ValueError, 'No such action "%s"'%action
             # call the mapped action
-            getattr(self, method)()
-        except Redirect:
-            raise
-        except Unauthorised:
-            raise
+            action_klass(self).handle()
+        except ValueError, err:
+            self.error_message.append(str(err))
 
     def write(self, content):
         if not self.headers_done:
@@ -677,1230 +620,12 @@ class Client:
                 self.db.close()
             self.db = self.instance.open(user)
 
-    #
-    # Actions
-    #
-    def loginAction(self):
-        ''' Attempt to log a user in.
-
-            Sets up a session for the user which contains the login
-            credentials.
-        '''
-        # we need the username at a minimum
-        if not self.form.has_key('__login_name'):
-            self.error_message.append(_('Username required'))
-            return
-
-        # get the login info
-        self.user = self.form['__login_name'].value
-        if self.form.has_key('__login_password'):
-            password = self.form['__login_password'].value
-        else:
-            password = ''
-
-        # make sure the user exists
-        try:
-            self.userid = self.db.user.lookup(self.user)
-        except KeyError:
-            name = self.user
-            self.error_message.append(_('No such user "%(name)s"')%locals())
-            self.make_user_anonymous()
-            return
-
-        # verify the password
-        if not self.verifyPassword(self.userid, password):
-            self.make_user_anonymous()
-            self.error_message.append(_('Incorrect password'))
-            return
-
-        # make sure we're allowed to be here
-        if not self.loginPermission():
-            self.make_user_anonymous()
-            self.error_message.append(_("You do not have permission to login"))
-            return
-
-        # now we're OK, re-open the database for real, using the user
-        self.opendb(self.user)
-
-        # set the session cookie
-        self.set_cookie(self.user)
-
-    def verifyPassword(self, userid, password):
-        ''' Verify the password that the user has supplied
-        '''
-        stored = self.db.user.get(self.userid, 'password')
-        if password == stored:
-            return 1
-        if not password and not stored:
-            return 1
-        return 0
-
-    def loginPermission(self):
-        ''' Determine whether the user has permission to log in.
-
-            Base behaviour is to check the user has "Web Access".
-        ''' 
-        if not self.db.security.hasPermission('Web Access', self.userid):
-            return 0
-        return 1
-
-    def logout_action(self):
-        ''' Make us really anonymous - nuke the cookie too
-        '''
-        # log us out
-        self.make_user_anonymous()
-
-        # construct the logout cookie
-        now = Cookie._getdate()
-        self.additional_headers['Set-Cookie'] = \
-           '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
-            now, self.cookie_path)
-
-        # Let the user know what's going on
-        self.ok_message.append(_('You are logged out'))
-
-    def registerAction(self):
-        '''Attempt to create a new user based on the contents of the form
-        and then set the cookie.
-
-        return 1 on successful login
-        '''
-        props = self.parsePropsFromForm()[0][('user', None)]
-
-        # make sure we're allowed to register
-        if not self.registerPermission(props):
-            raise Unauthorised, _("You do not have permission to register")
-
-        try:
-            self.db.user.lookup(props['username'])
-            self.error_message.append('Error: A user with the username "%s" '
-                'already exists'%props['username'])
-            return
-        except KeyError:
-            pass
-
-        # generate the one-time-key and store the props for later
-        otk = ''.join([random.choice(chars) for x in range(32)])
-        for propname, proptype in self.db.user.getprops().items():
-            value = props.get(propname, None)
-            if value is None:
-                pass
-            elif isinstance(proptype, hyperdb.Date):
-                props[propname] = str(value)
-            elif isinstance(proptype, hyperdb.Interval):
-                props[propname] = str(value)
-            elif isinstance(proptype, hyperdb.Password):
-                props[propname] = str(value)
-        props['__time'] = time.time()
-        self.db.otks.set(otk, **props)
-
-        # send the email
-        tracker_name = self.db.config.TRACKER_NAME
-        tracker_email = self.db.config.TRACKER_EMAIL
-        subject = 'Complete your registration to %s -- key %s' % (tracker_name,
-                                                                  otk)
-        body = """To complete your registration of the user "%(name)s" with
-%(tracker)s, please do one of the following:
-
-- send a reply to %(tracker_email)s and maintain the subject line as is (the
-reply's additional "Re:" is ok),
-
-- or visit the following URL:
-
-   %(url)s?@action=confrego&otk=%(otk)s
-""" % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
-       'otk': otk, 'tracker_email': tracker_email}
-        if not self.standard_message([props['address']], subject, body,
-                                     tracker_email):
-            return
-
-        # commit changes to the database
-        self.db.commit()
-
-        # redirect to the "you're almost there" page
-        raise Redirect, '%suser?@template=rego_progress'%self.base
-
     def standard_message(self, to, subject, body, author=None):
         try:
             self.mailer.standard_message(to, subject, body, author)
             return 1
         except MessageSendError, e:
             self.error_message.append(str(e))
-            
-    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 confRegoAction(self):
-        ''' Grab the OTK, use it to load up the new user details
-        '''
-        try:
-            # pull the rego information out of the otk database
-            self.userid = self.db.confirm_registration(self.form['otk'].value)
-        except (ValueError, KeyError), message:
-            # XXX: we need to make the "default" page be able to display errors!
-            self.error_message.append(str(message))
-            return
-        
-        # log the new user in
-        self.user = self.db.user.get(self.userid, 'username')
-        # re-open the database for real, using the user
-        self.opendb(self.user)
-
-        # if we have a session, update it
-        if hasattr(self, 'session'):
-            self.db.sessions.set(self.session, user=self.user,
-                last_use=time.time())
-        else:
-            # new session cookie
-            self.set_cookie(self.user)
-
-        # nice message
-        message = _('You are now registered, welcome!')
-
-        # redirect to the user's page
-        raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
-            self.userid, urllib.quote(message))
-
-    def passResetAction(self):
-        ''' Handle password reset requests.
-
-            Presence of either "name" or "address" generate email.
-            Presense of "otk" performs the reset.
-        '''
-        if self.form.has_key('otk'):
-            # pull the rego information out of the otk database
-            otk = self.form['otk'].value
-            uid = self.db.otks.get(otk, 'uid')
-            if uid is None:
-                self.error_message.append("""Invalid One Time Key!
-(a Mozilla bug may cause this message to show up erroneously,
- please check your email)""")
-                return
-
-            # re-open the database as "admin"
-            if self.user != 'admin':
-                self.opendb('admin')
-
-            # change the password
-            newpw = password.generatePassword()
-
-            cl = self.db.user
-# XXX we need to make the "default" page be able to display errors!
-            try:
-                # set the password
-                cl.set(uid, password=password.Password(newpw))
-                # clear the props from the otk database
-                self.db.otks.destroy(otk)
-                self.db.commit()
-            except (ValueError, KeyError), message:
-                self.error_message.append(str(message))
-                return
-
-            # user info
-            address = self.db.user.get(uid, 'address')
-            name = self.db.user.get(uid, 'username')
-
-            # send the email
-            tracker_name = self.db.config.TRACKER_NAME
-            subject = 'Password reset for %s'%tracker_name
-            body = '''
-The password has been reset for username "%(name)s".
-
-Your password is now: %(password)s
-'''%{'name': name, 'password': newpw}
-            if not self.standard_message([address], subject, body):
-                return
-
-            self.ok_message.append('Password reset and email sent to %s' %
-                                   address)
-            return
-
-        # no OTK, so now figure the user
-        if self.form.has_key('username'):
-            name = self.form['username'].value
-            try:
-                uid = self.db.user.lookup(name)
-            except KeyError:
-                self.error_message.append('Unknown username')
-                return
-            address = self.db.user.get(uid, 'address')
-        elif self.form.has_key('address'):
-            address = self.form['address'].value
-            uid = uidFromAddress(self.db, ('', address), create=0)
-            if not uid:
-                self.error_message.append('Unknown email address')
-                return
-            name = self.db.user.get(uid, 'username')
-        else:
-            self.error_message.append('You need to specify a username '
-                'or address')
-            return
-
-        # generate the one-time-key and store the props for later
-        otk = ''.join([random.choice(chars) for x in range(32)])
-        self.db.otks.set(otk, uid=uid, __time=time.time())
-
-        # send the email
-        tracker_name = self.db.config.TRACKER_NAME
-        subject = 'Confirm reset of password for %s'%tracker_name
-        body = '''
-Someone, perhaps you, has requested that the password be changed for your
-username, "%(name)s". If you wish to proceed with the change, please follow
-the link below:
-
-  %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
-
-You should then receive another email with the new password.
-'''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
-        if not self.standard_message([address], subject, body):
-            return
-
-        self.ok_message.append('Email sent to %s'%address)
-
-    def editItemAction(self):
-        ''' Perform an edit of an item in the database.
-
-           See parsePropsFromForm and _editnodes for special variables
-        '''
-        props, links = self.parsePropsFromForm()
-
-        # handle the props
-        try:
-            message = self._editnodes(props, links)
-        except (ValueError, KeyError, IndexError), message:
-            self.error_message.append(_('Apply Error: ') + str(message))
-            return
-
-        # commit now that all the tricky stuff is done
-        self.db.commit()
-
-        # redirect to the item's edit page
-        raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
-            self.classname, self.nodeid, urllib.quote(message),
-            urllib.quote(self.template))
-
-    newItemAction = editItemAction
-
-    def editItemPermission(self, props):
-        """Determine whether the user has permission to edit this item.
-
-        Base behaviour is to check the user can edit this class. If we're
-        editing the"user" class, users are allowed to edit their own details.
-        Unless it's the "roles" property, which requires the special Permission
-        "Web Roles".
-        """
-        # if this is a user node and the user is editing their own node, then
-        # we're OK
-        has = self.db.security.hasPermission
-        if self.classname == 'user':
-            # reject if someone's trying to edit "roles" and doesn't have the
-            # right permission.
-            if props.has_key('roles') and not has('Web Roles', self.userid,
-                    'user'):
-                return 0
-            # if the item being edited is the current user, we're ok
-            if (self.nodeid == self.userid
-                and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
-                return 1
-        if self.db.security.hasPermission('Edit', self.userid, self.classname):
-            return 1
-        return 0
-
-    def newItemPermission(self, props):
-        ''' Determine whether the user has permission to create (edit) this
-            item.
-
-            Base behaviour is to check the user can edit this class. No
-            additional property checks are made. Additionally, new user items
-            may be created if the user has the "Web Registration" Permission.
-        '''
-        has = self.db.security.hasPermission
-        if self.classname == 'user' and has('Web Registration', self.userid,
-                'user'):
-            return 1
-        if has('Edit', self.userid, self.classname):
-            return 1
-        return 0
-
-
-    #
-    #  Utility methods for editing
-    #
-    def _editnodes(self, all_props, all_links, newids=None):
-        ''' Use the props in all_props to perform edit and creation, then
-            use the link specs in all_links to do linking.
-        '''
-        # figure dependencies and re-work links
-        deps = {}
-        links = {}
-        for cn, nodeid, propname, vlist in all_links:
-            if not all_props.has_key((cn, nodeid)):
-                # link item to link to doesn't (and won't) exist
-                continue
-            for value in vlist:
-                if not all_props.has_key(value):
-                    # link item to link to doesn't (and won't) exist
-                    continue
-                deps.setdefault((cn, nodeid), []).append(value)
-                links.setdefault(value, []).append((cn, nodeid, propname))
-
-        # figure chained dependencies ordering
-        order = []
-        done = {}
-        # loop detection
-        change = 0
-        while len(all_props) != len(done):
-            for needed in all_props.keys():
-                if done.has_key(needed):
-                    continue
-                tlist = deps.get(needed, [])
-                for target in tlist:
-                    if not done.has_key(target):
-                        break
-                else:
-                    done[needed] = 1
-                    order.append(needed)
-                    change = 1
-            if not change:
-                raise ValueError, 'linking must not loop!'
-
-        # now, edit / create
-        m = []
-        for needed in order:
-            props = all_props[needed]
-            if not props:
-                # nothing to do
-                continue
-            cn, nodeid = needed
-
-            if nodeid is not None and int(nodeid) > 0:
-                # make changes to the node
-                props = self._changenode(cn, nodeid, props)
-
-                # and some nice feedback for the user
-                if props:
-                    info = ', '.join(props.keys())
-                    m.append('%s %s %s edited ok'%(cn, nodeid, info))
-                else:
-                    m.append('%s %s - nothing changed'%(cn, nodeid))
-            else:
-                assert props
-
-                # make a new node
-                newid = self._createnode(cn, props)
-                if nodeid is None:
-                    self.nodeid = newid
-                nodeid = newid
-
-                # and some nice feedback for the user
-                m.append('%s %s created'%(cn, newid))
-
-            # fill in new ids in links
-            if links.has_key(needed):
-                for linkcn, linkid, linkprop in links[needed]:
-                    props = all_props[(linkcn, linkid)]
-                    cl = self.db.classes[linkcn]
-                    propdef = cl.getprops()[linkprop]
-                    if not props.has_key(linkprop):
-                        if linkid is None or linkid.startswith('-'):
-                            # linking to a new item
-                            if isinstance(propdef, hyperdb.Multilink):
-                                props[linkprop] = [newid]
-                            else:
-                                props[linkprop] = newid
-                        else:
-                            # linking to an existing item
-                            if isinstance(propdef, hyperdb.Multilink):
-                                existing = cl.get(linkid, linkprop)[:]
-                                existing.append(nodeid)
-                                props[linkprop] = existing
-                            else:
-                                props[linkprop] = newid
-
-        return '<br>'.join(m)
-
-    def _changenode(self, cn, nodeid, props):
-        ''' change the node based on the contents of the form
-        '''
-        # check for permission
-        if not self.editItemPermission(props):
-            raise Unauthorised, 'You do not have permission to edit %s'%cn
-
-        # make the changes
-        cl = self.db.classes[cn]
-        return cl.set(nodeid, **props)
-
-    def _createnode(self, cn, props):
-        ''' create a node based on the contents of the form
-        '''
-        # check for permission
-        if not self.newItemPermission(props):
-            raise Unauthorised, 'You do not have permission to create %s'%cn
-
-        # create the node and return its id
-        cl = self.db.classes[cn]
-        return cl.create(**props)
-
-    # 
-    # More actions
-    #
-    def editCSVAction(self):
-        """ Performs an edit of all of a class' items in one go.
-
-            The "rows" CGI var defines the CSV-formatted entries for the
-            class. New nodes are identified by the ID 'X' (or any other
-            non-existent ID) and removed lines are retired.
-        """
-        # this is per-class only
-        if not self.editCSVPermission():
-            self.error_message.append(
-                 _('You do not have permission to edit %s' %self.classname))
-            return
-
-        # get the CSV module
-        if rcsv.error:
-            self.error_message.append(_(rcsv.error))
-            return
-
-        cl = self.db.classes[self.classname]
-        idlessprops = cl.getprops(protected=0).keys()
-        idlessprops.sort()
-        props = ['id'] + idlessprops
-
-        # do the edit
-        rows = StringIO.StringIO(self.form['rows'].value)
-        reader = rcsv.reader(rows, rcsv.comma_separated)
-        found = {}
-        line = 0
-        for values in reader:
-            line += 1
-            if line == 1: continue
-            # skip property names header
-            if values == props:
-                continue
-
-            # extract the nodeid
-            nodeid, values = values[0], values[1:]
-            found[nodeid] = 1
-
-            # see if the node exists
-            if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
-                exists = 0
-            else:
-                exists = 1
-
-            # confirm correct weight
-            if len(idlessprops) != len(values):
-                self.error_message.append(
-                    _('Not enough values on line %(line)s')%{'line':line})
-                return
-
-            # extract the new values
-            d = {}
-            for name, value in zip(idlessprops, values):
-                prop = cl.properties[name]
-                value = value.strip()
-                # only add the property if it has a value
-                if value:
-                    # if it's a multilink, split it
-                    if isinstance(prop, hyperdb.Multilink):
-                        value = value.split(':')
-                    elif isinstance(prop, hyperdb.Password):
-                        value = password.Password(value)
-                    elif isinstance(prop, hyperdb.Interval):
-                        value = date.Interval(value)
-                    elif isinstance(prop, hyperdb.Date):
-                        value = date.Date(value)
-                    elif isinstance(prop, hyperdb.Boolean):
-                        value = value.lower() in ('yes', 'true', 'on', '1')
-                    elif isinstance(prop, hyperdb.Number):
-                        value = float(value)
-                    d[name] = value
-                elif exists:
-                    # nuke the existing value
-                    if isinstance(prop, hyperdb.Multilink):
-                        d[name] = []
-                    else:
-                        d[name] = None
-
-            # perform the edit
-            if exists:
-                # edit existing
-                cl.set(nodeid, **d)
-            else:
-                # new node
-                found[cl.create(**d)] = 1
-
-        # retire the removed entries
-        for nodeid in cl.list():
-            if not found.has_key(nodeid):
-                cl.retire(nodeid)
-
-        # all OK
-        self.db.commit()
-
-        self.ok_message.append(_('Items edited OK'))
-
-    def editCSVPermission(self):
-        ''' Determine whether the user has permission to edit this class.
-
-            Base behaviour is to check the user can edit this class.
-        ''' 
-        if not self.db.security.hasPermission('Edit', self.userid,
-                self.classname):
-            return 0
-        return 1
-
-    def searchAction(self, wcre=re.compile(r'[\s,]+')):
-        ''' Mangle some of the form variables.
-
-            Set the form ":filter" variable based on the values of the
-            filter variables - if they're set to anything other than
-            "dontcare" then add them to :filter.
-
-            Handle the ":queryname" variable and save off the query to
-            the user's query list.
-
-            Split any String query values on whitespace and comma.
-        '''
-        # generic edit is per-class only
-        if not self.searchPermission():
-            self.error_message.append(
-                _('You do not have permission to search %s' %self.classname))
-            return
-
-        # add a faked :filter form variable for each filtering prop
-        props = self.db.classes[self.classname].getprops()
-        queryname = ''
-        for key in self.form.keys():
-            # special vars
-            if self.FV_QUERYNAME.match(key):
-                queryname = self.form[key].value.strip()
-                continue
-
-            if not props.has_key(key):
-                continue
-            if isinstance(self.form[key], type([])):
-                # search for at least one entry which is not empty
-                for minifield in self.form[key]:
-                    if minifield.value:
-                        break
-                else:
-                    continue
-            else:
-                if not self.form[key].value:
-                    continue
-                if isinstance(props[key], hyperdb.String):
-                    v = self.form[key].value
-                    l = token.token_split(v)
-                    if len(l) > 1 or l[0] != v:
-                        self.form.value.remove(self.form[key])
-                        # replace the single value with the split list
-                        for v in l:
-                            self.form.value.append(cgi.MiniFieldStorage(key, v))
-
-            self.form.value.append(cgi.MiniFieldStorage('@filter', key))
-
-        # handle saving the query params
-        if queryname:
-            # parse the environment and figure what the query _is_
-            req = templating.HTMLRequest(self)
-
-            # The [1:] strips off the '?' character, it isn't part of the
-            # query string.
-            url = req.indexargs_href('', {})[1:]
-
-            # handle editing an existing query
-            try:
-                qid = self.db.query.lookup(queryname)
-                self.db.query.set(qid, klass=self.classname, url=url)
-            except KeyError:
-                # create a query
-                qid = self.db.query.create(name=queryname,
-                    klass=self.classname, url=url)
-
-            # and add it to the user's query multilink
-            queries = self.db.user.get(self.userid, 'queries')
-            queries.append(qid)
-            self.db.user.set(self.userid, queries=queries)
-
-            # commit the query change to the database
-            self.db.commit()
-
-    def searchPermission(self):
-        ''' Determine whether the user has permission to search this class.
-
-            Base behaviour is to check the user can view this class.
-        ''' 
-        if not self.db.security.hasPermission('View', self.userid,
-                self.classname):
-            return 0
-        return 1
-
-
-    def retireAction(self):
-        ''' Retire the context item.
-        '''
-        # if we want to view the index template now, then unset the nodeid
-        # context info (a special-case for retire actions on the index page)
-        nodeid = self.nodeid
-        if self.template == 'index':
-            self.nodeid = None
-
-        # generic edit is per-class only
-        if not self.retirePermission():
-            self.error_message.append(
-                _('You do not have permission to retire %s' %self.classname))
-            return
-
-        # make sure we don't try to retire admin or anonymous
-        if self.classname == 'user' and \
-                self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
-            self.error_message.append(
-                _('You may not retire the admin or anonymous user'))
-            return
-
-        # do the retire
-        self.db.getclass(self.classname).retire(nodeid)
-        self.db.commit()
-
-        self.ok_message.append(
-            _('%(classname)s %(itemid)s has been retired')%{
-                'classname': self.classname.capitalize(), 'itemid': nodeid})
-
-    def retirePermission(self):
-        ''' Determine whether the user has permission to retire this class.
-
-            Base behaviour is to check the user can edit this class.
-        ''' 
-        if not self.db.security.hasPermission('Edit', self.userid,
-                self.classname):
-            return 0
-        return 1
-
-
-    def showAction(self, typere=re.compile('[@:]type'),
-            numre=re.compile('[@:]number')):
-        ''' Show a node of a particular class/id
-        '''
-        t = n = ''
-        for key in self.form.keys():
-            if typere.match(key):
-                t = self.form[key].value.strip()
-            elif numre.match(key):
-                n = self.form[key].value.strip()
-        if not t:
-            raise ValueError, 'Invalid %s number'%t
-        url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
-        raise Redirect, url
-
-    def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
-        """ Item properties and their values are edited with html FORM
-            variables and their values. You can:
-
-            - Change the value of some property of the current item.
-            - Create a new item of any class, and edit the new item's
-              properties,
-            - Attach newly created items to a multilink property of the
-              current item.
-            - Remove items from a multilink property of the current item.
-            - Specify that some properties are required for the edit
-              operation to be successful.
-
-            In the following, <bracketed> values are variable, "@" may be
-            either ":" or "@", and other text "required" is fixed.
-
-            Most properties are specified as form variables:
-
-             <propname>
-              - property on the current context item
-
-             <designator>"@"<propname>
-              - property on the indicated item (for editing related
-                information)
-
-            Designators name a specific item of a class.
-
-            <classname><N>
-
-                Name an existing item of class <classname>.
-
-            <classname>"-"<N>
-
-                Name the <N>th new item of class <classname>. If the form
-                submission is successful, a new item of <classname> is
-                created. Within the submitted form, a particular
-                designator of this form always refers to the same new
-                item.
-
-            Once we have determined the "propname", we look at it to see
-            if it's special:
-
-            @required
-                The associated form value is a comma-separated list of
-                property names that must be specified when the form is
-                submitted for the edit operation to succeed.  
-
-                When the <designator> is missing, the properties are
-                for the current context item.  When <designator> is
-                present, they are for the item specified by
-                <designator>.
-
-                The "@required" specifier must come before any of the
-                properties it refers to are assigned in the form.
-
-            @remove@<propname>=id(s) or @add@<propname>=id(s)
-                The "@add@" and "@remove@" edit actions apply only to
-                Multilink properties.  The form value must be a
-                comma-separate list of keys for the class specified by
-                the simple form variable.  The listed items are added
-                to (respectively, removed from) the specified
-                property.
-
-            @link@<propname>=<designator>
-                If the edit action is "@link@", the simple form
-                variable must specify a Link or Multilink property.
-                The form value is a comma-separated list of
-                designators.  The item corresponding to each
-                designator is linked to the property given by simple
-                form variable.  These are collected up and returned in
-                all_links.
-
-            None of the above (ie. just a simple form value)
-                The value of the form variable is converted
-                appropriately, depending on the type of the property.
-
-                For a Link('klass') property, the form value is a
-                single key for 'klass', where the key field is
-                specified in dbinit.py.  
-
-                For a Multilink('klass') property, the form value is a
-                comma-separated list of keys for 'klass', where the
-                key field is specified in dbinit.py.  
-
-                Note that for simple-form-variables specifiying Link
-                and Multilink properties, the linked-to class must
-                have a key field.
-
-                For a String() property specifying a filename, the
-                file named by the form value is uploaded. This means we
-                try to set additional properties "filename" and "type" (if
-                they are valid for the class).  Otherwise, the property
-                is set to the form value.
-
-                For Date(), Interval(), Boolean(), and Number()
-                properties, the form value is converted to the
-                appropriate
-
-            Any of the form variables may be prefixed with a classname or
-            designator.
-
-            Two special form values are supported for backwards
-            compatibility:
-
-            @note
-                This is equivalent to::
-
-                    @link@messages=msg-1
-                    msg-1@content=value
-
-                except that in addition, the "author" and "date"
-                properties of "msg-1" are set to the userid of the
-                submitter, and the current time, respectively.
-
-            @file
-                This is equivalent to::
-
-                    @link@files=file-1
-                    file-1@content=value
-
-                The String content value is handled as described above for
-                file uploads.
-
-            If both the "@note" and "@file" form variables are
-            specified, the action::
-
-                    @link@msg-1@files=file-1
-
-            is also performed.
-
-            We also check that FileClass items have a "content" property with
-            actual content, otherwise we remove them from all_props before
-            returning.
-
-            The return from this method is a dict of 
-                (classname, id): properties
-            ... this dict _always_ has an entry for the current context,
-            even if it's empty (ie. a submission for an existing issue that
-            doesn't result in any changes would return {('issue','123'): {}})
-            The id may be None, which indicates that an item should be
-            created.
-        """
-        # some very useful variables
-        db = self.db
-        form = self.form
-
-        if not hasattr(self, 'FV_SPECIAL'):
-            # generate the regexp for handling special form values
-            classes = '|'.join(db.classes.keys())
-            # specials for parsePropsFromForm
-            # handle the various forms (see unit tests)
-            self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
-            self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
-
-        # these indicate the default class / item
-        default_cn = self.classname
-        default_cl = self.db.classes[default_cn]
-        default_nodeid = self.nodeid
-
-        # we'll store info about the individual class/item edit in these
-        all_required = {}       # required props per class/item
-        all_props = {}          # props to set per class/item
-        got_props = {}          # props received per class/item
-        all_propdef = {}        # note - only one entry per class
-        all_links = []          # as many as are required
-
-        # we should always return something, even empty, for the context
-        all_props[(default_cn, default_nodeid)] = {}
-
-        keys = form.keys()
-        timezone = db.getUserTimezone()
-
-        # sentinels for the :note and :file props
-        have_note = have_file = 0
-
-        # extract the usable form labels from the form
-        matches = []
-        for key in keys:
-            m = self.FV_SPECIAL.match(key)
-            if m:
-                matches.append((key, m.groupdict()))
-
-        # now handle the matches
-        for key, d in matches:
-            if d['classname']:
-                # we got a designator
-                cn = d['classname']
-                cl = self.db.classes[cn]
-                nodeid = d['id']
-                propname = d['propname']
-            elif d['note']:
-                # the special note field
-                cn = 'msg'
-                cl = self.db.classes[cn]
-                nodeid = '-1'
-                propname = 'content'
-                all_links.append((default_cn, default_nodeid, 'messages',
-                    [('msg', '-1')]))
-                have_note = 1
-            elif d['file']:
-                # the special file field
-                cn = 'file'
-                cl = self.db.classes[cn]
-                nodeid = '-1'
-                propname = 'content'
-                all_links.append((default_cn, default_nodeid, 'files',
-                    [('file', '-1')]))
-                have_file = 1
-            else:
-                # default
-                cn = default_cn
-                cl = default_cl
-                nodeid = default_nodeid
-                propname = d['propname']
-
-            # the thing this value relates to is...
-            this = (cn, nodeid)
-
-            # get more info about the class, and the current set of
-            # form props for it
-            if not all_propdef.has_key(cn):
-                all_propdef[cn] = cl.getprops()
-            propdef = all_propdef[cn]
-            if not all_props.has_key(this):
-                all_props[this] = {}
-            props = all_props[this]
-            if not got_props.has_key(this):
-                got_props[this] = {}
-
-            # is this a link command?
-            if d['link']:
-                value = []
-                for entry in extractFormList(form[key]):
-                    m = self.FV_DESIGNATOR.match(entry)
-                    if not m:
-                        raise FormError, \
-                            'link "%s" value "%s" not a designator'%(key, entry)
-                    value.append((m.group(1), m.group(2)))
-
-                # make sure the link property is valid
-                if (not isinstance(propdef[propname], hyperdb.Multilink) and
-                        not isinstance(propdef[propname], hyperdb.Link)):
-                    raise FormError, '%s %s is not a link or '\
-                        'multilink property'%(cn, propname)
-
-                all_links.append((cn, nodeid, propname, value))
-                continue
-
-            # detect the special ":required" variable
-            if d['required']:
-                all_required[this] = extractFormList(form[key])
-                continue
-
-            # see if we're performing a special multilink action
-            mlaction = 'set'
-            if d['remove']:
-                mlaction = 'remove'
-            elif d['add']:
-                mlaction = 'add'
-
-            # does the property exist?
-            if not propdef.has_key(propname):
-                if mlaction != 'set':
-                    raise FormError, 'You have submitted a %s action for'\
-                        ' the property "%s" which doesn\'t exist'%(mlaction,
-                        propname)
-                # the form element is probably just something we don't care
-                # about - ignore it
-                continue
-            proptype = propdef[propname]
-
-            # Get the form value. This value may be a MiniFieldStorage or a list
-            # of MiniFieldStorages.
-            value = form[key]
-
-            # handle unpacking of the MiniFieldStorage / list form value
-            if isinstance(proptype, hyperdb.Multilink):
-                value = extractFormList(value)
-            else:
-                # multiple values are not OK
-                if isinstance(value, type([])):
-                    raise FormError, 'You have submitted more than one value'\
-                        ' for the %s property'%propname
-                # value might be a file upload...
-                if not hasattr(value, 'filename') or value.filename is None:
-                    # nope, pull out the value and strip it
-                    value = value.value.strip()
-
-            # now that we have the props field, we need a teensy little
-            # extra bit of help for the old :note field...
-            if d['note'] and value:
-                props['author'] = self.db.getuid()
-                props['date'] = date.Date()
-
-            # handle by type now
-            if isinstance(proptype, hyperdb.Password):
-                if not value:
-                    # ignore empty password values
-                    continue
-                for key, d in matches:
-                    if d['confirm'] and d['propname'] == propname:
-                        confirm = form[key]
-                        break
-                else:
-                    raise FormError, 'Password and confirmation text do '\
-                        'not match'
-                if isinstance(confirm, type([])):
-                    raise FormError, 'You have submitted more than one value'\
-                        ' for the %s property'%propname
-                if value != confirm.value:
-                    raise FormError, 'Password and confirmation text do '\
-                        'not match'
-                try:
-                    value = password.Password(value)
-                except hyperdb.HyperdbValueError, msg:
-                    raise FormError, msg
-
-            elif isinstance(proptype, hyperdb.Multilink):
-                # convert input to list of ids
-                try:
-                    l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
-                        propname, value)
-                except hyperdb.HyperdbValueError, msg:
-                    raise FormError, msg
-
-                # 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]
-                    elif nodeid and not nodeid.startswith('-'):
-                        existing = cl.get(nodeid, propname, [])
-                    else:
-                        existing = []
-
-                    # 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 FormError, _('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 value == '':
-                # other types should be None'd if there's no value
-                value = None
-            else:
-                # handle all other types
-                try:
-                    if isinstance(proptype, hyperdb.String):
-                        if (hasattr(value, 'filename') and
-                                value.filename is not None):
-                            # skip if the upload is empty
-                            if not value.filename:
-                                continue
-                            # this String is actually a _file_
-                            # try to determine the file content-type
-                            fn = value.filename.split('\\')[-1]
-                            if propdef.has_key('name'):
-                                props['name'] = fn
-                            # use this info as the type/filename properties
-                            if propdef.has_key('type'):
-                                if hasattr(value, 'type') and value.type:
-                                    props['type'] = value.type
-                                elif mimetypes.guess_type(fn)[0]:
-                                    props['type'] = mimetypes.guess_type(fn)[0]
-                                else:
-                                    props['type'] = "application/octet-stream"
-                            # finally, read the content RAW
-                            value = value.value
-                        else:
-                            value = hyperdb.rawToHyperdb(self.db, cl,
-                                nodeid, propname, value)
-
-                    else:
-                        value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
-                            propname, value)
-                except hyperdb.HyperdbValueError, msg:
-                    raise FormError, msg
-
-            # register that we got this property
-            if value:
-                got_props[this][propname] = 1
-
-            # get the old value
-            if nodeid and not nodeid.startswith('-'):
-                try:
-                    existing = cl.get(nodeid, propname)
-                except KeyError:
-                    # this might be a new property for which there is
-                    # no existing value
-                    if not propdef.has_key(propname):
-                        raise
-                except IndexError, message:
-                    raise FormError(str(message))
-
-                # make sure the existing multilink is sorted
-                if isinstance(proptype, hyperdb.Multilink):
-                    existing.sort()
-
-                # "missing" existing values may not be None
-                if not existing:
-                    if isinstance(proptype, hyperdb.String) and not existing:
-                        # some backends store "missing" Strings as empty strings
-                        existing = None
-                    elif isinstance(proptype, hyperdb.Number) and not existing:
-                        # some backends store "missing" Numbers as 0 :(
-                        existing = 0
-                    elif isinstance(proptype, hyperdb.Boolean) and not existing:
-                        # likewise Booleans
-                        existing = 0
-
-                # if changed, set it
-                if value != existing:
-                    props[propname] = value
-            else:
-                # don't bother setting empty/unset values
-                if value is None:
-                    continue
-                elif isinstance(proptype, hyperdb.Multilink) and value == []:
-                    continue
-                elif isinstance(proptype, hyperdb.String) and value == '':
-                    continue
-
-                props[propname] = value
-
-        # check to see if we need to specially link a file to the note
-        if have_note and have_file:
-            all_links.append(('msg', '-1', 'files', [('file', '-1')]))
-
-        # see if all the required properties have been supplied
-        s = []
-        for thing, required in all_required.items():
-            # register the values we got
-            got = got_props.get(thing, {})
-            for entry in required[:]:
-                if got.has_key(entry):
-                    required.remove(entry)
-
-            # any required values not present?
-            if not required:
-                continue
-
-            # tell the user to entry the values required
-            if len(required) > 1:
-                p = 'properties'
-            else:
-                p = 'property'
-            s.append('Required %s %s %s not supplied'%(thing[0], p,
-                ', '.join(required)))
-        if s:
-            raise FormError, '\n'.join(s)
-
-        # When creating a FileClass node, it should have a non-empty content
-        # property to be created. When editing a FileClass node, it should
-        # either have a non-empty content property or no property at all. In
-        # the latter case, nothing will change.
-        for (cn, id), props in all_props.items():
-            if isinstance(self.db.classes[cn], hyperdb.FileClass):
-                if id == '-1':
-                    if not props.get('content', ''):
-                        del all_props[(cn, id)]
-                elif props.has_key('content') and not props['content']:
-                    raise FormError, _('File is empty')
-        return all_props, all_links
-
-def extractFormList(value):
-    ''' Extract a list of values from the form value.
-
-        It may be one of:
-         [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
-         MiniFieldStorage('value,value,...')
-         MiniFieldStorage('value')
-    '''
-    # multiple values are OK
-    if isinstance(value, type([])):
-        # it's a list of MiniFieldStorages - join then into
-        values = ','.join([i.value.strip() for i in value])
-    else:
-        # it's a MiniFieldStorage, but may be a comma-separated list
-        # of values
-        values = value.value
-
-    value = [i.strip() for i in values.split(',')]
-
-    # filter out the empty bits
-    return filter(None, value)
 
+    def parsePropsFromForm(self):
+        return FormParser(self).parse()
diff --git a/roundup/cgi/exceptions.py b/roundup/cgi/exceptions.py
new file mode 100755 (executable)
index 0000000..1ae37e1
--- /dev/null
@@ -0,0 +1,32 @@
+class HTTPException(Exception):
+    pass
+
+class Unauthorised(HTTPException):
+    pass
+
+class Redirect(HTTPException):
+    pass
+
+class NotFound(HTTPException):
+    pass
+
+class NotModified(HTTPException):
+    pass
+
+class FormError(ValueError):
+    """An 'expected' exception occurred during form parsing.
+
+    That is, something we know can go wrong, and don't want to alarm the user
+    with.
+
+    We trap this at the user interface level and feed back a nice error to the
+    user.
+
+    """
+    pass
+
+class SendFile(Exception):
+    """Send a file from the database."""
+
+class SendStaticFile(Exception):
+    """Send a static file from the instance html directory."""
diff --git a/roundup/cgi/form_parser.py b/roundup/cgi/form_parser.py
new file mode 100755 (executable)
index 0000000..ded96e4
--- /dev/null
@@ -0,0 +1,536 @@
+import re, mimetypes
+
+from roundup import hyperdb, date, password
+from roundup.cgi.exceptions import FormError
+from roundup.i18n import _
+
+class FormParser:
+    # edit form variable handling (see unit tests)
+    FV_LABELS = r'''
+       ^(
+         (?P<note>[@:]note)|
+         (?P<file>[@:]file)|
+         (
+          ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
+          ((?P<required>[@:]required$)|       # :required
+           (
+            (
+             (?P<add>[@:]add[@:])|            # :add:<prop>
+             (?P<remove>[@:]remove[@:])|      # :remove:<prop>
+             (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
+             (?P<link>[@:]link[@:])|          # :link:<prop>
+             ([@:])                           # just a separator
+            )?
+            (?P<propname>[^@:]+)             # <prop>
+           )
+          )
+         )
+        )$'''
+    
+    def __init__(self, client):
+        self.client = client
+        self.db = client.db
+        self.form = client.form
+        self.classname = client.classname
+        self.nodeid = client.nodeid
+      
+    def parse(self, num_re=re.compile('^\d+$')):
+        """ Item properties and their values are edited with html FORM
+            variables and their values. You can:
+
+            - Change the value of some property of the current item.
+            - Create a new item of any class, and edit the new item's
+              properties,
+            - Attach newly created items to a multilink property of the
+              current item.
+            - Remove items from a multilink property of the current item.
+            - Specify that some properties are required for the edit
+              operation to be successful.
+
+            In the following, <bracketed> values are variable, "@" may be
+            either ":" or "@", and other text "required" is fixed.
+
+            Most properties are specified as form variables:
+
+             <propname>
+              - property on the current context item
+
+             <designator>"@"<propname>
+              - property on the indicated item (for editing related
+                information)
+
+            Designators name a specific item of a class.
+
+            <classname><N>
+
+                Name an existing item of class <classname>.
+
+            <classname>"-"<N>
+
+                Name the <N>th new item of class <classname>. If the form
+                submission is successful, a new item of <classname> is
+                created. Within the submitted form, a particular
+                designator of this form always refers to the same new
+                item.
+
+            Once we have determined the "propname", we look at it to see
+            if it's special:
+
+            @required
+                The associated form value is a comma-separated list of
+                property names that must be specified when the form is
+                submitted for the edit operation to succeed.  
+
+                When the <designator> is missing, the properties are
+                for the current context item.  When <designator> is
+                present, they are for the item specified by
+                <designator>.
+
+                The "@required" specifier must come before any of the
+                properties it refers to are assigned in the form.
+
+            @remove@<propname>=id(s) or @add@<propname>=id(s)
+                The "@add@" and "@remove@" edit actions apply only to
+                Multilink properties.  The form value must be a
+                comma-separate list of keys for the class specified by
+                the simple form variable.  The listed items are added
+                to (respectively, removed from) the specified
+                property.
+
+            @link@<propname>=<designator>
+                If the edit action is "@link@", the simple form
+                variable must specify a Link or Multilink property.
+                The form value is a comma-separated list of
+                designators.  The item corresponding to each
+                designator is linked to the property given by simple
+                form variable.  These are collected up and returned in
+                all_links.
+
+            None of the above (ie. just a simple form value)
+                The value of the form variable is converted
+                appropriately, depending on the type of the property.
+
+                For a Link('klass') property, the form value is a
+                single key for 'klass', where the key field is
+                specified in dbinit.py.  
+
+                For a Multilink('klass') property, the form value is a
+                comma-separated list of keys for 'klass', where the
+                key field is specified in dbinit.py.  
+
+                Note that for simple-form-variables specifiying Link
+                and Multilink properties, the linked-to class must
+                have a key field.
+
+                For a String() property specifying a filename, the
+                file named by the form value is uploaded. This means we
+                try to set additional properties "filename" and "type" (if
+                they are valid for the class).  Otherwise, the property
+                is set to the form value.
+
+                For Date(), Interval(), Boolean(), and Number()
+                properties, the form value is converted to the
+                appropriate
+
+            Any of the form variables may be prefixed with a classname or
+            designator.
+
+            Two special form values are supported for backwards
+            compatibility:
+
+            @note
+                This is equivalent to::
+
+                    @link@messages=msg-1
+                    msg-1@content=value
+
+                except that in addition, the "author" and "date"
+                properties of "msg-1" are set to the userid of the
+                submitter, and the current time, respectively.
+
+            @file
+                This is equivalent to::
+
+                    @link@files=file-1
+                    file-1@content=value
+
+                The String content value is handled as described above for
+                file uploads.
+
+            If both the "@note" and "@file" form variables are
+            specified, the action::
+
+                    @link@msg-1@files=file-1
+
+            is also performed.
+
+            We also check that FileClass items have a "content" property with
+            actual content, otherwise we remove them from all_props before
+            returning.
+
+            The return from this method is a dict of 
+                (classname, id): properties
+            ... this dict _always_ has an entry for the current context,
+            even if it's empty (ie. a submission for an existing issue that
+            doesn't result in any changes would return {('issue','123'): {}})
+            The id may be None, which indicates that an item should be
+            created.
+        """
+        # some very useful variables
+        db = self.db
+        form = self.form
+
+        if not hasattr(self, 'FV_SPECIAL'):
+            # generate the regexp for handling special form values
+            classes = '|'.join(db.classes.keys())
+            # specials for parsePropsFromForm
+            # handle the various forms (see unit tests)
+            self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
+            self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
+
+        # these indicate the default class / item
+        default_cn = self.classname
+        default_cl = self.db.classes[default_cn]
+        default_nodeid = self.nodeid
+
+        # we'll store info about the individual class/item edit in these
+        all_required = {}       # required props per class/item
+        all_props = {}          # props to set per class/item
+        got_props = {}          # props received per class/item
+        all_propdef = {}        # note - only one entry per class
+        all_links = []          # as many as are required
+
+        # we should always return something, even empty, for the context
+        all_props[(default_cn, default_nodeid)] = {}
+
+        keys = form.keys()
+        timezone = db.getUserTimezone()
+
+        # sentinels for the :note and :file props
+        have_note = have_file = 0
+
+        # extract the usable form labels from the form
+        matches = []
+        for key in keys:
+            m = self.FV_SPECIAL.match(key)
+            if m:
+                matches.append((key, m.groupdict()))
+
+        # now handle the matches
+        for key, d in matches:
+            if d['classname']:
+                # we got a designator
+                cn = d['classname']
+                cl = self.db.classes[cn]
+                nodeid = d['id']
+                propname = d['propname']
+            elif d['note']:
+                # the special note field
+                cn = 'msg'
+                cl = self.db.classes[cn]
+                nodeid = '-1'
+                propname = 'content'
+                all_links.append((default_cn, default_nodeid, 'messages',
+                    [('msg', '-1')]))
+                have_note = 1
+            elif d['file']:
+                # the special file field
+                cn = 'file'
+                cl = self.db.classes[cn]
+                nodeid = '-1'
+                propname = 'content'
+                all_links.append((default_cn, default_nodeid, 'files',
+                    [('file', '-1')]))
+                have_file = 1
+            else:
+                # default
+                cn = default_cn
+                cl = default_cl
+                nodeid = default_nodeid
+                propname = d['propname']
+
+            # the thing this value relates to is...
+            this = (cn, nodeid)
+
+            # get more info about the class, and the current set of
+            # form props for it
+            if not all_propdef.has_key(cn):
+                all_propdef[cn] = cl.getprops()
+            propdef = all_propdef[cn]
+            if not all_props.has_key(this):
+                all_props[this] = {}
+            props = all_props[this]
+            if not got_props.has_key(this):
+                got_props[this] = {}
+
+            # is this a link command?
+            if d['link']:
+                value = []
+                for entry in self.extractFormList(form[key]):
+                    m = self.FV_DESIGNATOR.match(entry)
+                    if not m:
+                        raise FormError, \
+                            'link "%s" value "%s" not a designator'%(key, entry)
+                    value.append((m.group(1), m.group(2)))
+
+                # make sure the link property is valid
+                if (not isinstance(propdef[propname], hyperdb.Multilink) and
+                        not isinstance(propdef[propname], hyperdb.Link)):
+                    raise FormError, '%s %s is not a link or '\
+                        'multilink property'%(cn, propname)
+
+                all_links.append((cn, nodeid, propname, value))
+                continue
+
+            # detect the special ":required" variable
+            if d['required']:
+                all_required[this] = self.extractFormList(form[key])
+                continue
+
+            # see if we're performing a special multilink action
+            mlaction = 'set'
+            if d['remove']:
+                mlaction = 'remove'
+            elif d['add']:
+                mlaction = 'add'
+
+            # does the property exist?
+            if not propdef.has_key(propname):
+                if mlaction != 'set':
+                    raise FormError, 'You have submitted a %s action for'\
+                        ' the property "%s" which doesn\'t exist'%(mlaction,
+                        propname)
+                # the form element is probably just something we don't care
+                # about - ignore it
+                continue
+            proptype = propdef[propname]
+
+            # Get the form value. This value may be a MiniFieldStorage or a list
+            # of MiniFieldStorages.
+            value = form[key]
+
+            # handle unpacking of the MiniFieldStorage / list form value
+            if isinstance(proptype, hyperdb.Multilink):
+                value = self.extractFormList(value)
+            else:
+                # multiple values are not OK
+                if isinstance(value, type([])):
+                    raise FormError, 'You have submitted more than one value'\
+                        ' for the %s property'%propname
+                # value might be a file upload...
+                if not hasattr(value, 'filename') or value.filename is None:
+                    # nope, pull out the value and strip it
+                    value = value.value.strip()
+
+            # now that we have the props field, we need a teensy little
+            # extra bit of help for the old :note field...
+            if d['note'] and value:
+                props['author'] = self.db.getuid()
+                props['date'] = date.Date()
+
+            # handle by type now
+            if isinstance(proptype, hyperdb.Password):
+                if not value:
+                    # ignore empty password values
+                    continue
+                for key, d in matches:
+                    if d['confirm'] and d['propname'] == propname:
+                        confirm = form[key]
+                        break
+                else:
+                    raise FormError, 'Password and confirmation text do '\
+                        'not match'
+                if isinstance(confirm, type([])):
+                    raise FormError, 'You have submitted more than one value'\
+                        ' for the %s property'%propname
+                if value != confirm.value:
+                    raise FormError, 'Password and confirmation text do '\
+                        'not match'
+                try:
+                    value = password.Password(value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
+
+            elif isinstance(proptype, hyperdb.Multilink):
+                # convert input to list of ids
+                try:
+                    l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+                        propname, value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
+
+                # 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]
+                    elif nodeid and not nodeid.startswith('-'):
+                        existing = cl.get(nodeid, propname, [])
+                    else:
+                        existing = []
+
+                    # 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 FormError, _('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 value == '':
+                # other types should be None'd if there's no value
+                value = None
+            else:
+                # handle all other types
+                try:
+                    if isinstance(proptype, hyperdb.String):
+                        if (hasattr(value, 'filename') and
+                                value.filename is not None):
+                            # skip if the upload is empty
+                            if not value.filename:
+                                continue
+                            # this String is actually a _file_
+                            # try to determine the file content-type
+                            fn = value.filename.split('\\')[-1]
+                            if propdef.has_key('name'):
+                                props['name'] = fn
+                            # use this info as the type/filename properties
+                            if propdef.has_key('type'):
+                                if hasattr(value, 'type') and value.type:
+                                    props['type'] = value.type
+                                elif mimetypes.guess_type(fn)[0]:
+                                    props['type'] = mimetypes.guess_type(fn)[0]
+                                else:
+                                    props['type'] = "application/octet-stream"
+                            # finally, read the content RAW
+                            value = value.value
+                        else:
+                            value = hyperdb.rawToHyperdb(self.db, cl,
+                                nodeid, propname, value)
+
+                    else:
+                        value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+                            propname, value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
+
+            # register that we got this property
+            if value:
+                got_props[this][propname] = 1
+
+            # get the old value
+            if nodeid and not nodeid.startswith('-'):
+                try:
+                    existing = cl.get(nodeid, propname)
+                except KeyError:
+                    # this might be a new property for which there is
+                    # no existing value
+                    if not propdef.has_key(propname):
+                        raise
+                except IndexError, message:
+                    raise FormError(str(message))
+
+                # make sure the existing multilink is sorted
+                if isinstance(proptype, hyperdb.Multilink):
+                    existing.sort()
+
+                # "missing" existing values may not be None
+                if not existing:
+                    if isinstance(proptype, hyperdb.String) and not existing:
+                        # some backends store "missing" Strings as empty strings
+                        existing = None
+                    elif isinstance(proptype, hyperdb.Number) and not existing:
+                        # some backends store "missing" Numbers as 0 :(
+                        existing = 0
+                    elif isinstance(proptype, hyperdb.Boolean) and not existing:
+                        # likewise Booleans
+                        existing = 0
+
+                # if changed, set it
+                if value != existing:
+                    props[propname] = value
+            else:
+                # don't bother setting empty/unset values
+                if value is None:
+                    continue
+                elif isinstance(proptype, hyperdb.Multilink) and value == []:
+                    continue
+                elif isinstance(proptype, hyperdb.String) and value == '':
+                    continue
+
+                props[propname] = value
+
+        # check to see if we need to specially link a file to the note
+        if have_note and have_file:
+            all_links.append(('msg', '-1', 'files', [('file', '-1')]))
+
+        # see if all the required properties have been supplied
+        s = []
+        for thing, required in all_required.items():
+            # register the values we got
+            got = got_props.get(thing, {})
+            for entry in required[:]:
+                if got.has_key(entry):
+                    required.remove(entry)
+
+            # any required values not present?
+            if not required:
+                continue
+
+            # tell the user to entry the values required
+            if len(required) > 1:
+                p = 'properties'
+            else:
+                p = 'property'
+            s.append('Required %s %s %s not supplied'%(thing[0], p,
+                ', '.join(required)))
+        if s:
+            raise FormError, '\n'.join(s)
+
+        # When creating a FileClass node, it should have a non-empty content
+        # property to be created. When editing a FileClass node, it should
+        # either have a non-empty content property or no property at all. In
+        # the latter case, nothing will change.
+        for (cn, id), props in all_props.items():
+            if isinstance(self.db.classes[cn], hyperdb.FileClass):
+                if id == '-1':
+                    if not props.get('content', ''):
+                        del all_props[(cn, id)]
+                elif props.has_key('content') and not props['content']:
+                    raise FormError, _('File is empty')
+        return all_props, all_links
+
+    def extractFormList(self, value):
+        ''' Extract a list of values from the form value.
+
+            It may be one of:
+             [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
+             MiniFieldStorage('value,value,...')
+             MiniFieldStorage('value')
+        '''
+        # multiple values are OK
+        if isinstance(value, type([])):
+            # it's a list of MiniFieldStorages - join then into
+            values = ','.join([i.value.strip() for i in value])
+        else:
+            # it's a MiniFieldStorage, but may be a comma-separated list
+            # of values
+            values = value.value
+
+        value = [i.strip() for i in values.split(',')]
+
+        # filter out the empty bits
+        return filter(None, value)
diff --git a/test/test_actions.py b/test/test_actions.py
new file mode 100755 (executable)
index 0000000..10cd733
--- /dev/null
@@ -0,0 +1,144 @@
+import unittest\r
+from cgi import FieldStorage, MiniFieldStorage\r
+\r
+from roundup import hyperdb\r
+from roundup.cgi.actions import *\r
+from roundup.cgi.exceptions import Redirect, Unauthorised\r
+\r
+class MockNull:\r
+    def __init__(self, **kwargs):\r
+        for key, value in kwargs.items():\r
+            setattr(self, key, value)\r
+\r
+    def __call__(self, *args, **kwargs): return MockNull()\r
+    def __getattr__(self, name):\r
+        # This allows assignments which assume all intermediate steps are Null\r
+        # objects if they don't exist yet.\r
+        #\r
+        # For example (with just 'client' defined):\r
+        #\r
+        # client.db.config.TRACKER_WEB = 'BASE/'        \r
+        setattr(self, name, MockNull())\r
+        return getattr(self, name)\r
+\r
+    def __getitem__(self, key): return self    \r
+    def __nonzero__(self): return 0\r
+    def __str__(self): return ''\r
+\r
+def true(*args, **kwargs):\r
+    return 1\r
+\r
+class ActionTestCase(unittest.TestCase):\r
+    def setUp(self):\r
+        self.form = FieldStorage()\r
+        self.client = MockNull()\r
+        self.client.form = self.form\r
+    \r
+class ShowActionTestCase(ActionTestCase):\r
+    def assertRaisesMessage(self, exception, callable, message, *args, **kwargs):\r
+        try:\r
+            callable(*args, **kwargs)\r
+        except exception, msg:\r
+            self.assertEqual(str(msg), message)\r
+        else:\r
+            if hasattr(excClass,'__name__'): excName = excClass.__name__\r
+            else: excName = str(excClass)\r
+            raise self.failureException, excName\r
+    \r
+    def testShowAction(self):\r
+        self.client.db.config.TRACKER_WEB = 'BASE/'\r
+\r
+        action = ShowAction(self.client)\r
+        self.assertRaises(ValueError, action.handle)\r
+\r
+        self.form.value.append(MiniFieldStorage('@type', 'issue'))\r
+        self.assertRaisesMessage(Redirect, action.handle, 'BASE/issue')\r
+        \r
+        self.form.value.append(MiniFieldStorage('@number', '1'))\r
+        self.assertRaisesMessage(Redirect, action.handle, 'BASE/issue1')\r
+\r
+class RetireActionTestCase(ActionTestCase):\r
+    def testRetireAction(self):\r
+        self.client.db.security.hasPermission = true\r
+        self.client.ok_message = []\r
+        RetireAction(self.client).handle()\r
+        self.assert_(len(self.client.ok_message) == 1)\r
+\r
+    def testNoPermission(self):\r
+        self.assertRaises(Unauthorised, RetireAction(self.client).handle)\r
+\r
+    def testDontRetireAdminOrAnonymous(self):\r
+        self.client.db.security.hasPermission=true\r
+        self.client.classname = 'user'        \r
+        self.client.db.user.get = lambda a,b: 'admin'\r
+        self.assertRaises(ValueError, RetireAction(self.client).handle)\r
+\r
+class SearchActionTestCase(ActionTestCase):\r
+    def setUp(self):\r
+        ActionTestCase.setUp(self)\r
+        self.action = SearchAction(self.client)\r
+\r
+class StandardSearchActionTestCase(SearchActionTestCase):\r
+    def testNoPermission(self):\r
+        self.assertRaises(Unauthorised, self.action.handle)\r
+    \r
+    def testQueryName(self):\r
+        self.assertEqual(self.action.getQueryName(), '')\r
+\r
+        self.form.value.append(MiniFieldStorage('@queryname', 'foo'))\r
+        self.assertEqual(self.action.getQueryName(), 'foo')\r
+\r
+class FakeFilterVarsTestCase(SearchActionTestCase):\r
+    def setUp(self):\r
+        SearchActionTestCase.setUp(self)\r
+        self.client.db.classes.getprops = lambda: {'foo': hyperdb.Multilink('foo')}\r
+\r
+    def assertFilterEquals(self, expected):\r
+        self.action.fakeFilterVars()\r
+        self.assertEqual(self.form.getvalue('@filter'), expected)\r
+        \r
+    def testEmptyMultilink(self):\r
+        self.form.value.append(MiniFieldStorage('foo', ''))\r
+        self.form.value.append(MiniFieldStorage('foo', ''))\r
+\r
+        self.assertFilterEquals(None)\r
+\r
+    def testNonEmptyMultilink(self):\r
+        self.form.value.append(MiniFieldStorage('foo', ''))\r
+        self.form.value.append(MiniFieldStorage('foo', '1'))\r
+\r
+        self.assertFilterEquals('foo')\r
+\r
+    def testEmptyKey(self):\r
+        self.form.value.append(MiniFieldStorage('foo', ''))\r
+        self.assertFilterEquals(None)\r
+\r
+    def testStandardKey(self):\r
+        self.form.value.append(MiniFieldStorage('foo', '1'))\r
+        self.assertFilterEquals('foo')\r
+\r
+    def testStringKey(self):\r
+        self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()}\r
+        self.form.value.append(MiniFieldStorage('foo', 'hello'))\r
+        self.assertFilterEquals('foo')\r
+\r
+    def testTokenizedStringKey(self):\r
+        self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()}\r
+        self.form.value.append(MiniFieldStorage('foo', 'hello world'))\r
+        \r
+        self.assertFilterEquals('foo')\r
+\r
+        # The single value gets replaced with the tokenized list.\r
+        self.assertEqual([x.value for x in self.form['foo']], ['hello', 'world'])\r
+        \r
+def test_suite():\r
+    suite = unittest.TestSuite()\r
+    suite.addTest(unittest.makeSuite(RetireActionTestCase))\r
+    suite.addTest(unittest.makeSuite(StandardSearchActionTestCase))\r
+    suite.addTest(unittest.makeSuite(FakeFilterVarsTestCase))\r
+    suite.addTest(unittest.makeSuite(ShowActionTestCase))    \r
+    return suite\r
+\r
+if __name__ == '__main__':\r
+    runner = unittest.TextTestRunner()\r
+    unittest.main(testRunner=runner)\r
index e4f26bb5f6385a5e5e34e2be2b4462347a7790b5..5af001145441735568100d921d2c055baf91cefa 100644 (file)
@@ -8,12 +8,13 @@
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 #
-# $Id: test_cgi.py,v 1.21 2003-10-25 22:53:26 richard Exp $
+# $Id: test_cgi.py,v 1.22 2004-02-11 21:34:31 jlgijsbers Exp $
 
 import unittest, os, shutil, errno, sys, difflib, cgi, re
 
 from roundup.cgi import client
-from roundup.cgi.client import FormError
+from roundup.cgi.errors import FormError
+from roundup.cgi.FormParser import FormParser
 from roundup import init, instance, password, hyperdb, date
 
 NEEDS_INSTANCE = 1
@@ -89,7 +90,7 @@ class FormTestCase(unittest.TestCase):
 
         # compile the labels re
         classes = '|'.join(self.db.classes.keys())
-        self.FV_SPECIAL = re.compile(client.Client.FV_LABELS%classes,
+        self.FV_SPECIAL = re.compile(FormParser.FV_LABELS%classes,
             re.VERBOSE)
 
     def parseForm(self, form, classname='test', nodeid=None):