Code

minor pre-release / test fixes
[roundup.git] / roundup / cgi / actions.py
index 994a96f5aa681b889b7bfdab198a2807f816fc22..33e7281399c16268aa20d34f75ae2ac60994828f 100755 (executable)
@@ -3,12 +3,13 @@ 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.cgi.exceptions import Redirect, Unauthorised, SeriousError
 from roundup.mailgw import uidFromAddress
 
 __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
            'EditCSVAction', 'EditItemAction', 'PassResetAction',
-           'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction']
+           'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction',
+           'NewItemAction']
 
 # used by a couple of routines
 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
@@ -24,17 +25,35 @@ class Action:
         self.userid = client.userid
         self.base = client.base
         self.user = client.user
-        
-    def handle(self):
+
+    def execute(self):
         """Execute the action specified by this object."""
-        raise NotImplementedError
+        self.permission()
+        self.handle()
 
+    name = ''
+    permissionType = None
     def permission(self):
         """Check whether the user has permission to execute this action.
 
-        True by default.
+        True by default. If the permissionType attribute is a string containing
+        a simple permission, check whether the user has that permission.
+        Subclasses must also define the name attribute if they define
+        permissionType.
+
+        Despite having this permission, users may still be unauthorised to
+        perform parts of actions. It is up to the subclasses to detect this.
         """
-        return 1
+        if (self.permissionType and
+                not self.hasPermission(self.permissionType)):
+            info = {'action': self.name, 'classname': self.classname}
+            raise Unauthorised, _('You do not have permission to '
+                '%(action)s the %(classname)s class.')%info
+
+    def hasPermission(self, permission):
+        """Check whether the user has 'permission' on the current class."""
+        return self.db.security.hasPermission(permission, self.client.userid,
+            self.client.classname)
 
 class ShowAction(Action):
     def handle(self, typere=re.compile('[@:]type'),
@@ -47,11 +66,22 @@ class ShowAction(Action):
             elif numre.match(key):
                 n = self.form[key].value.strip()
         if not t:
-            raise ValueError, 'Invalid %s number'%t
+            raise ValueError, 'No type specified'
+        if not n:
+            raise SeriousError, _('No ID entered')
+        try:
+            int(n)
+        except ValueError:
+            d = {'input': n, 'classname': t}
+            raise SeriousError, _(
+                '"%(input)s" is not an ID (%(classname)s ID required)')%d
         url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
         raise Redirect, url
 
 class RetireAction(Action):
+    name = 'retire'
+    permissionType = 'Edit'
+
     def handle(self):
         """Retire the context item."""
         # if we want to view the index template now, then unset the nodeid
@@ -60,11 +90,6 @@ class RetireAction(Action):
         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'):
@@ -78,15 +103,10 @@ class RetireAction(Action):
             _('%(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):
+    name = 'search'
+    permissionType = 'View'
+
     def handle(self, wcre=re.compile(r'[\s,]+')):
         """Mangle some of the form variables.
 
@@ -100,13 +120,8 @@ class SearchAction(Action):
         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()        
+        queryname = self.getQueryName()
 
         # handle saving the query params
         if queryname:
@@ -128,8 +143,9 @@ class SearchAction(Action):
 
             # 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)
+            if qid not in queries:
+                queries.append(qid)
+                self.db.user.set(self.userid, queries=queries)
 
             # commit the query change to the database
             self.db.commit()
@@ -158,7 +174,7 @@ class SearchAction(Action):
                         # 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')
@@ -167,12 +183,11 @@ class SearchAction(Action):
             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):
+    name = 'edit'
+    permissionType = 'Edit'
+
     def handle(self):
         """Performs an edit of all of a class' items in one go.
 
@@ -181,12 +196,6 @@ class EditCSVAction(Action):
         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))
@@ -271,57 +280,26 @@ class EditCSVAction(Action):
 
         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
+class _EditAction(Action):
+    def isEditingSelf(self):
+        """Check whether a user is editing his/her own details."""
+        return (self.nodeid == self.userid
+                and self.db.user.get(self.nodeid, 'username') != 'anonymous')
 
-        # 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.
+        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'):
+            if props.has_key('roles') and not self.hasPermission('Web Roles'):
+                raise Unauthorised, _("You do not have permission to edit user roles")
+            if self.isEditingSelf():
                 return 1
-        if self.db.security.hasPermission('Edit', self.userid, self.classname):
+        if self.hasPermission('Edit'):
             return 1
         return 0
 
@@ -333,11 +311,8 @@ class EditItemAction(Action):
         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):
+        if (self.classname == 'user' and self.hasPermission('Web Registration')
+            or self.hasPermission('Edit')):
             return 1
         return 0
 
@@ -407,7 +382,7 @@ class EditItemAction(Action):
                 # make a new node
                 newid = self._createnode(cn, props)
                 if nodeid is None:
-                    self.client.nodeid = newid
+                    self.nodeid = newid
                 nodeid = newid
 
                 # and some nice feedback for the user
@@ -456,19 +431,110 @@ class EditItemAction(Action):
         # create the node and return its id
         cl = self.db.classes[cn]
         return cl.create(**props)
-        
+
+class EditItemAction(_EditAction):
+    def lastUserActivity(self):
+        if self.form.has_key(':lastactivity'):
+            return date.Date(self.form[':lastactivity'].value)
+        elif self.form.has_key('@lastactivity'):
+            return date.Date(self.form['@lastactivity'].value)
+        else:
+            return None
+
+    def lastNodeActivity(self):
+        cl = getattr(self.client.db, self.classname)
+        return cl.get(self.nodeid, 'activity')
+
+    def detectCollision(self, userActivity, nodeActivity):
+        # Result from lastUserActivity may be None. If it is, assume there's no
+        # conflict, or at least not one we can detect.
+        if userActivity:
+            return userActivity < nodeActivity
+
+    def handleCollision(self):
+        self.client.template = 'collision'
+
+    def handle(self):
+        """Perform an edit of an item in the database.
+
+        See parsePropsFromForm and _editnodes for special variables.
+
+        """
+        if self.detectCollision(self.lastUserActivity(), self.lastNodeActivity()):
+            self.handleCollision()
+            return
+
+        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
+        # redirect to finish off
+        url = self.base + self.classname
+        # note that this action might have been called by an index page, so
+        # we will want to include index-page args in this URL too
+        if self.nodeid is not None:
+            url += self.nodeid
+        url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
+            urllib.quote(self.template))
+        if self.nodeid is None:
+            req = templating.HTMLRequest(self)
+            url += '&' + req.indexargs_href('', {})[1:]
+        raise Redirect, url
+
+class NewItemAction(_EditAction):
+    def handle(self):
+        ''' Add a new item to the database.
+
+            This follows the same form as the EditItemAction, with the same
+            special form values.
+        '''
+        # parse the props from the form
+        try:
+            props, links = self.client.parsePropsFromForm(create=1)
+        except (ValueError, KeyError), message:
+            self.client.error_message.append(_('Error: ') + str(message))
+            return
+
+        # handle the props - edit or create
+        try:
+            # when it hits the None element, it'll set self.nodeid
+            messages = self._editnodes(props, links)
+
+        except (ValueError, KeyError, IndexError), message:
+            # these errors might just be indicative of user dumbness
+            self.client.error_message.append(_('Error: ') + str(message))
+            return
+
+        # commit now that all the tricky stuff is done
+        self.db.commit()
+
+        # redirect to the new item's page
+        raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
+            self.classname, self.nodeid, urllib.quote(messages),
+            urllib.quote(self.template))
+
 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')
+            otks = self.db.getOTKManager()
+            uid = 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,
@@ -484,12 +550,12 @@ class PassResetAction(Action):
             newpw = password.generatePassword()
 
             cl = self.db.user
-# XXX we need to make the "default" page be able to display errors!
+            # 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)
+                otks.destroy(otk)
                 self.db.commit()
             except (ValueError, KeyError), message:
                 self.client.error_message.append(str(message))
@@ -510,8 +576,8 @@ Your password is now: %(password)s
             if not self.client.standard_message([address], subject, body):
                 return
 
-            self.client.ok_message.append('Password reset and email sent to %s' %
-                                          address)
+            self.client.ok_message.append(
+                    'Password reset and email sent to %s'%address)
             return
 
         # no OTK, so now figure the user
@@ -537,7 +603,10 @@ Your password is now: %(password)s
 
         # 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())
+        while otks.exists(otk):
+            otk = ''.join([random.choice(chars) for x in range(32)])
+        otks.set(otk, uid=uid)
+        self.db.commit()
 
         # send the email
         tracker_name = self.db.config.TRACKER_NAME
@@ -563,19 +632,17 @@ class ConfRegoAction(Action):
             # 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,
+            self.client.db.sessions.set(self.session, user=self.user,
                 last_use=time.time())
         else:
             # new session cookie
@@ -583,34 +650,44 @@ class ConfRegoAction(Action):
 
         # nice message
         message = _('You are now registered, welcome!')
+        url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
+            urllib.quote(message))
 
-        # redirect to the user's page
-        raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
-                                                   self.userid, urllib.quote(message))
+        # redirect to the user's page (but not 302, as some email clients seem
+        # to want to reload the page, or something)
+        return '''<html><head><title>%s</title></head>
+            <body><p><a href="%s">%s</a></p>
+            <script type="text/javascript">
+            window.setTimeout('window.location = "%s"', 1000);
+            </script>'''%(message, url, message, url)
 
 class RegisterAction(Action):
+    name = 'register'
+    permissionType = 'Web Registration'
+
     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)]
+        props = self.client.parsePropsFromForm(create=1)[0][('user', None)]
 
-        # make sure we're allowed to register
-        if not self.permission(props):
-            raise Unauthorised, _("You do not have permission to register")
+        # registration isn't allowed to supply roles
+        if props.has_key('roles'):
+            raise Unauthorised, _("It is not permitted to supply roles "
+                "at registration.")
 
+        username = props['username']
         try:
-            self.db.user.lookup(props['username'])
-            self.client.error_message.append('Error: A user with the username "%s" '
-                'already exists'%props['username'])
+            self.db.user.lookup(username)
+            self.client.error_message.append(_('Error: A user with the '
+                'username "%(username)s" already exists')%props)
             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:
@@ -621,13 +698,15 @@ class RegisterAction(Action):
                 props[propname] = str(value)
             elif isinstance(proptype, hyperdb.Password):
                 props[propname] = str(value)
-        props['__time'] = time.time()
-        self.db.otks.set(otk, **props)
+        otks = self.db.getOTKManager()
+        while otks.exists(otk):
+            otk = ''.join([random.choice(chars) for x in range(32)])
+        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,
+        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:
@@ -650,19 +729,6 @@ reply's additional "Re:" is ok),
         # 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."""
@@ -712,8 +778,9 @@ class LoginAction(Action):
             self.client.error_message.append(_('Incorrect password'))
             return
 
-        # make sure we're allowed to be here
-        if not self.permission():
+        # Determine whether the user has permission to log in.
+        # Base behaviour is to check the user has "Web Access".
+        if not self.hasPermission("Web Access"):
             self.client.make_user_anonymous()
             self.client.error_message.append(_("You do not have permission to login"))
             return
@@ -733,13 +800,3 @@ class LoginAction(Action):
         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