diff roundup/cgi/actions.py @ 2004:1782fe36e7b8

Move out parts of client.py to new modules: * 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.
author Johannes Gijsbers <jlgijsbers@users.sourceforge.net>
date Wed, 11 Feb 2004 21:34:31 +0000
parents
children 1b11ffd8015e
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/roundup/cgi/actions.py	Wed Feb 11 21:34:31 2004 +0000
@@ -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

Roundup Issue Tracker: http://roundup-tracker.org/