Code

Proper handling of 'Create' permissions in both mail gateway (earlier
[roundup.git] / roundup / cgi / actions.py
index 33e7281399c16268aa20d34f75ae2ac60994828f..36dfee3707f96a02c0c12c0cd49c70f66c8f718c 100755 (executable)
@@ -1,15 +1,16 @@
-import re, cgi, StringIO, urllib, Cookie, time, random
+import re, cgi, StringIO, urllib, time, random, csv, codecs
 
-from roundup import hyperdb, token, date, password, rcsv
+from roundup import hyperdb, token, date, password
+from roundup.actions import Action as BaseAction
 from roundup.i18n import _
-from roundup.cgi import templating
-from roundup.cgi.exceptions import Redirect, Unauthorised, SeriousError
+import roundup.exceptions
+from roundup.cgi import exceptions, templating
 from roundup.mailgw import uidFromAddress
 
 __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
            'EditCSVAction', 'EditItemAction', 'PassResetAction',
            'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction',
-           'NewItemAction']
+           'NewItemAction', 'ExportCSVAction']
 
 # used by a couple of routines
 chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
@@ -25,11 +26,16 @@ class Action:
         self.userid = client.userid
         self.base = client.base
         self.user = client.user
+        self.context = templating.context(client)
+
+    def handle(self):
+        """Action handler procedure"""
+        raise NotImplementedError
 
     def execute(self):
         """Execute the action specified by this object."""
         self.permission()
-        self.handle()
+        return self.handle()
 
     name = ''
     permissionType = None
@@ -47,36 +53,49 @@ class Action:
         if (self.permissionType and
                 not self.hasPermission(self.permissionType)):
             info = {'action': self.name, 'classname': self.classname}
-            raise Unauthorised, _('You do not have permission to '
+            raise exceptions.Unauthorised, self._(
+                'You do not have permission to '
                 '%(action)s the %(classname)s class.')%info
 
-    def hasPermission(self, permission):
+    _marker = []
+    def hasPermission(self, permission, classname=_marker, itemid=None, property=None):
         """Check whether the user has 'permission' on the current class."""
+        if classname is self._marker:
+            classname = self.client.classname
         return self.db.security.hasPermission(permission, self.client.userid,
-            self.client.classname)
+            classname=classname, itemid=itemid, property=property)
+
+    def gettext(self, msgid):
+        """Return the localized translation of msgid"""
+        return self.client.translator.gettext(msgid)
+
+    _ = gettext
 
 class ShowAction(Action):
-    def handle(self, typere=re.compile('[@:]type'),
-               numre=re.compile('[@:]number')):
+
+    typere=re.compile('[@:]type')
+    numre=re.compile('[@:]number')
+
+    def handle(self):
         """Show a node of a particular class/id."""
         t = n = ''
         for key in self.form.keys():
-            if typere.match(key):
+            if self.typere.match(key):
                 t = self.form[key].value.strip()
-            elif numre.match(key):
+            elif self.numre.match(key):
                 n = self.form[key].value.strip()
         if not t:
-            raise ValueError, 'No type specified'
+            raise ValueError, self._('No type specified')
         if not n:
-            raise SeriousError, _('No ID entered')
+            raise exceptions.SeriousError, self._('No ID entered')
         try:
             int(n)
         except ValueError:
             d = {'input': n, 'classname': t}
-            raise SeriousError, _(
+            raise exceptions.SeriousError, self._(
                 '"%(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
+        url = '%s%s%s'%(self.base, t, n)
+        raise exceptions.Redirect, url
 
 class RetireAction(Action):
     name = 'retire'
@@ -84,30 +103,43 @@ class RetireAction(Action):
 
     def handle(self):
         """Retire the context item."""
-        # if we want to view the index template now, then unset the nodeid
+        # ensure modification comes via POST
+        if self.client.env['REQUEST_METHOD'] != 'POST':
+            raise roundup.exceptions.Reject(self._('Invalid request'))
+
+        # if we want to view the index template now, then unset the itemid
         # context info (a special-case for retire actions on the index page)
-        nodeid = self.nodeid
+        itemid = self.nodeid
         if self.template == 'index':
             self.client.nodeid = None
 
         # 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')
+                self.db.user.get(itemid, 'username') in ('admin', 'anonymous'):
+            raise ValueError, self._(
+                'You may not retire the admin or anonymous user')
+
+        # check permission
+        if not self.hasPermission('Retire', classname=self.classname,
+                itemid=itemid):
+            raise exceptions.Unauthorised, self._(
+                'You do not have permission to retire %(class)s'
+            ) % {'class': self.classname}
 
         # do the retire
-        self.db.getclass(self.classname).retire(nodeid)
+        self.db.getclass(self.classname).retire(itemid)
         self.db.commit()
 
         self.client.ok_message.append(
-            _('%(classname)s %(itemid)s has been retired')%{
-                'classname': self.classname.capitalize(), 'itemid': nodeid})
+            self._('%(classname)s %(itemid)s has been retired')%{
+                'classname': self.classname.capitalize(), 'itemid': itemid})
+
 
 class SearchAction(Action):
     name = 'search'
     permissionType = 'View'
 
-    def handle(self, wcre=re.compile(r'[\s,]+')):
+    def handle(self):
         """Mangle some of the form variables.
 
         Set the form ":filter" variable based on the values of the filter
@@ -123,23 +155,61 @@ class SearchAction(Action):
         self.fakeFilterVars()
         queryname = self.getQueryName()
 
+        # editing existing query name?
+        old_queryname = self.getFromForm('old-queryname')
+
         # 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)
+            url = self.getCurrentURL(req)
+
+            key = self.db.query.getkey()
+            if key:
+                # edit the old way, only one query per name
+                try:
+                    qid = self.db.query.lookup(old_queryname)
+                    if not self.hasPermission('Edit', 'query', itemid=qid):
+                        raise exceptions.Unauthorised, self._(
+                            "You do not have permission to edit queries")
+                    self.db.query.set(qid, klass=self.classname, url=url)
+                except KeyError:
+                    # create a query
+                    if not self.hasPermission('Create', 'query'):
+                        raise exceptions.Unauthorised, self._(
+                            "You do not have permission to store queries")
+                    qid = self.db.query.create(name=queryname,
+                        klass=self.classname, url=url)
+            else:
+                # edit the new way, query name not a key any more
+                # see if we match an existing private query
+                uid = self.db.getuid()
+                qids = self.db.query.filter(None, {'name': old_queryname,
+                        'private_for': uid})
+                if not qids:
+                    # ok, so there's not a private query for the current user
+                    # - see if there's one created by them
+                    qids = self.db.query.filter(None, {'name': old_queryname,
+                        'creator': uid})
+
+                if qids and old_queryname:
+                    # edit query - make sure we get an exact match on the name
+                    for qid in qids:
+                        if old_queryname != self.db.query.get(qid, 'name'):
+                            continue
+                        if not self.hasPermission('Edit', 'query', itemid=qid):
+                            raise exceptions.Unauthorised, self._(
+                            "You do not have permission to edit queries")
+                        self.db.query.set(qid, klass=self.classname,
+                            url=url, name=queryname)
+                else:
+                    # create a query
+                    if not self.hasPermission('Create', 'query'):
+                        raise exceptions.Unauthorised, self._(
+                            "You do not have permission to store queries")
+                    qid = self.db.query.create(name=queryname,
+                        klass=self.classname, url=url, private_for=uid)
 
             # and add it to the user's query multilink
             queries = self.db.user.get(self.userid, 'queries')
@@ -152,9 +222,10 @@ class SearchAction(Action):
 
     def fakeFilterVars(self):
         """Add a faked :filter form variable for each filtering prop."""
-        props = self.db.classes[self.classname].getprops()
+        cls = self.db.classes[self.classname]
         for key in self.form.keys():
-            if not props.has_key(key):
+            prop = cls.get_transitive_prop(key)
+            if not prop:
                 continue
             if isinstance(self.form[key], type([])):
                 # search for at least one entry which is not empty
@@ -166,10 +237,10 @@ class SearchAction(Action):
             else:
                 if not self.form[key].value:
                     continue
-                if isinstance(props[key], hyperdb.String):
+                if isinstance(prop, hyperdb.String):
                     v = self.form[key].value
                     l = token.token_split(v)
-                    if len(l) > 1 or l[0] != 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:
@@ -177,13 +248,29 @@ class SearchAction(Action):
 
             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):
+    def getCurrentURL(self, req):
+        """Get current URL for storing as a query.
+
+        Note: We are removing the first character from the current URL,
+        because the leading '?' is not part of the query string.
+
+        Implementation note:
+        But maybe the template should be part of the stored query:
+        template = self.getFromForm('template')
+        if template:
+            return req.indexargs_url('', {'@template' : template})[1:]
+        """
+        return req.indexargs_url('', {})[1:]
+
+    def getFromForm(self, name):
+        for key in ('@' + name, ':' + name):
+            if self.form.has_key(key):
                 return self.form[key].value.strip()
         return ''
 
+    def getQueryName(self):
+        return self.getFromForm('queryname')
+
 class EditCSVAction(Action):
     name = 'edit'
     permissionType = 'Edit'
@@ -194,21 +281,23 @@ class EditCSVAction(Action):
         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.
-
         """
-        # get the CSV module
-        if rcsv.error:
-            self.client.error_message.append(_(rcsv.error))
-            return
+        # ensure modification comes via POST
+        if self.client.env['REQUEST_METHOD'] != 'POST':
+            raise roundup.exceptions.Reject(self._('Invalid request'))
 
+        # figure the properties list for the class
         cl = self.db.classes[self.classname]
-        idlessprops = cl.getprops(protected=0).keys()
-        idlessprops.sort()
-        props = ['id'] + idlessprops
+        props_without_id = cl.getprops(protected=0).keys()
+
+        # the incoming CSV data will always have the properties in colums
+        # sorted and starting with the "id" column
+        props_without_id.sort()
+        props = ['id'] + props_without_id
 
         # do the edit
         rows = StringIO.StringIO(self.form['rows'].value)
-        reader = rcsv.reader(rows, rcsv.comma_separated)
+        reader = csv.reader(rows)
         found = {}
         line = 0
         for values in reader:
@@ -218,25 +307,43 @@ class EditCSVAction(Action):
             if values == props:
                 continue
 
-            # extract the nodeid
-            nodeid, values = values[0], values[1:]
-            found[nodeid] = 1
+            # extract the itemid
+            itemid, values = values[0], values[1:]
+            found[itemid] = 1
 
             # see if the node exists
-            if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
+            if itemid in ('x', 'X') or not cl.hasnode(itemid):
                 exists = 0
+
+                # check permission to create this item
+                if not self.hasPermission('Create', classname=self.classname):
+                    raise exceptions.Unauthorised, self._(
+                        'You do not have permission to create %(class)s'
+                    ) % {'class': self.classname}
+            elif cl.hasnode(itemid) and cl.is_retired(itemid):
+                # If a CSV line just mentions an id and the corresponding
+                # item is retired, then the item is restored.
+                cl.restore(itemid)
+                continue
             else:
                 exists = 1
 
             # confirm correct weight
-            if len(idlessprops) != len(values):
+            if len(props_without_id) != len(values):
                 self.client.error_message.append(
-                    _('Not enough values on line %(line)s')%{'line':line})
+                    self._('Not enough values on line %(line)s')%{'line':line})
                 return
 
             # extract the new values
             d = {}
-            for name, value in zip(idlessprops, values):
+            for name, value in zip(props_without_id, values):
+                # check permission to edit this property on this item
+                if exists and not self.hasPermission('Edit', itemid=itemid,
+                        classname=self.classname, property=name):
+                    raise exceptions.Unauthorised, self._(
+                        'You do not have permission to edit %(class)s'
+                    ) % {'class': self.classname}
+
                 prop = cl.properties[name]
                 value = value.strip()
                 # only add the property if it has a value
@@ -265,61 +372,31 @@ class EditCSVAction(Action):
             # perform the edit
             if exists:
                 # edit existing
-                cl.set(nodeid, **d)
+                cl.set(itemid, **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)
+        for itemid in cl.list():
+            if not found.has_key(itemid):
+                # check permission to retire this item
+                if not self.hasPermission('Retire', itemid=itemid,
+                        classname=self.classname):
+                    raise exceptions.Unauthorised, self._(
+                        'You do not have permission to retire %(class)s'
+                    ) % {'class': self.classname}
+                cl.retire(itemid)
 
         # all OK
         self.db.commit()
 
-        self.client.ok_message.append(_('Items edited OK'))
-
-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')
-
-    def editItemPermission(self, props):
-        """Determine whether the user has permission to edit this item.
+        self.client.ok_message.append(self._('Items edited OK'))
 
-        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 self.classname == 'user':
-            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.hasPermission('Edit'):
-            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.
-
-        """
-        if (self.classname == 'user' and self.hasPermission('Web Registration')
-            or self.hasPermission('Edit')):
-            return 1
-        return 0
+class EditCommon(Action):
+    '''Utility methods for editing.'''
 
-    #
-    #  Utility methods for editing
-    #
-    def _editnodes(self, all_props, all_links, newids=None):
+    def _editnodes(self, all_props, all_links):
         ''' Use the props in all_props to perform edit and creation, then
             use the link specs in all_links to do linking.
         '''
@@ -327,9 +404,11 @@ class _EditAction(Action):
         deps = {}
         links = {}
         for cn, nodeid, propname, vlist in all_links:
-            if not all_props.has_key((cn, nodeid)):
+            numeric_id = int (nodeid or 0)
+            if not (numeric_id > 0 or 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
@@ -361,32 +440,33 @@ class _EditAction(Action):
         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))
+            if props:
+                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(map(self._, props.keys()))
+                        m.append(
+                            self._('%(class)s %(id)s %(properties)s edited ok')
+                            % {'class':cn, 'id':nodeid, 'properties':info})
+                    else:
+                        m.append(self._('%(class)s %(id)s - nothing changed')
+                            % {'class':cn, 'id':nodeid})
                 else:
-                    m.append('%s %s - nothing changed'%(cn, nodeid))
-            else:
-                assert props
+                    assert props
 
-                # make a new node
-                newid = self._createnode(cn, props)
-                if nodeid is None:
-                    self.nodeid = newid
-                nodeid = newid
+                    # 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))
+                    # and some nice feedback for the user
+                    m.append(self._('%(class)s %(id)s created')
+                        % {'class':cn, 'id':newid})
 
             # fill in new ids in links
             if links.has_key(needed):
@@ -398,9 +478,9 @@ class _EditAction(Action):
                         if linkid is None or linkid.startswith('-'):
                             # linking to a new item
                             if isinstance(propdef, hyperdb.Multilink):
-                                props[linkprop] = [newid]
+                                props[linkprop] = [nodeid]
                             else:
-                                props[linkprop] = newid
+                                props[linkprop] = nodeid
                         else:
                             # linking to an existing item
                             if isinstance(propdef, hyperdb.Multilink):
@@ -408,15 +488,17 @@ class _EditAction(Action):
                                 existing.append(nodeid)
                                 props[linkprop] = existing
                             else:
-                                props[linkprop] = newid
+                                props[linkprop] = nodeid
 
         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
+        if not self.editItemPermission(props, classname=cn, itemid=nodeid):
+            raise exceptions.Unauthorised, self._(
+                'You do not have permission to edit %(class)s'
+            ) % {'class': cn}
 
         # make the changes
         cl = self.db.classes[cn]
@@ -425,34 +507,93 @@ class _EditAction(Action):
     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
+        if not self.newItemPermission(props, classname=cn):
+            raise exceptions.Unauthorised, self._(
+                'You do not have permission to create %(class)s'
+            ) % {'class': cn}
 
         # create the node and return its id
         cl = self.db.classes[cn]
         return cl.create(**props)
 
-class EditItemAction(_EditAction):
+    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')
+
+    _cn_marker = []
+    def editItemPermission(self, props, classname=_cn_marker, itemid=None):
+        """Determine whether the user has permission to edit this item."""
+        if itemid is None:
+            itemid = self.nodeid
+        if classname is self._cn_marker:
+            classname = self.classname
+        # The user must have permission to edit each of the properties
+        # being changed.
+        for p in props:
+            if not self.hasPermission('Edit', itemid=itemid,
+                    classname=classname, property=p):
+                return 0
+        # Since the user has permission to edit all of the properties,
+        # the edit is OK.
+        return 1
+
+    def newItemPermission(self, props, classname=None):
+        """Determine whether the user has permission to create this item.
+
+        Base behaviour is to check the user can edit this class. No additional
+        property checks are made.
+        """
+
+        if not classname :
+            classname = self.client.classname
+        
+        if not self.hasPermission('Create', classname=classname):
+            return 0
+
+        # Check Create permission for each property, to avoid being able
+        # to set restricted ones on new item creation
+        for key in props:
+            if not self.hasPermission('Create', classname=classname,
+                                      property=key):
+                return 0
+        return 1
+
+class EditItemAction(EditCommon):
     def lastUserActivity(self):
         if self.form.has_key(':lastactivity'):
-            return date.Date(self.form[':lastactivity'].value)
+            d = date.Date(self.form[':lastactivity'].value)
         elif self.form.has_key('@lastactivity'):
-            return date.Date(self.form['@lastactivity'].value)
+            d = date.Date(self.form['@lastactivity'].value)
         else:
             return None
+        d.second = int(d.second)
+        return d
 
     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
+        activity = cl.get(self.nodeid, 'activity').local(0)
+        activity.second = int(activity.second)
+        return activity
+
+    def detectCollision(self, user_activity, node_activity):
+        '''Check for a collision and return the list of props we edited
+        that conflict.'''
+        if user_activity and user_activity < node_activity:
+            props, links = self.client.parsePropsFromForm()
+            key = (self.classname, self.nodeid)
+            # we really only collide for direct prop edit conflicts
+            return props[key].keys()
+        else:
+            return []
 
-    def handleCollision(self):
-        self.client.template = 'collision'
+    def handleCollision(self, props):
+        message = self._('Edit Error: someone else has edited this %s (%s). '
+            'View <a target="new" href="%s%s">their changes</a> '
+            'in a new window.')%(self.classname, ', '.join(props),
+            self.classname, self.nodeid)
+        self.client.error_message.append(message)
+        return
 
     def handle(self):
         """Perform an edit of an item in the database.
@@ -460,17 +601,26 @@ class EditItemAction(_EditAction):
         See parsePropsFromForm and _editnodes for special variables.
 
         """
-        if self.detectCollision(self.lastUserActivity(), self.lastNodeActivity()):
-            self.handleCollision()
-            return
+        # ensure modification comes via POST
+        if self.client.env['REQUEST_METHOD'] != 'POST':
+            raise roundup.exceptions.Reject(self._('Invalid request'))
+
+        user_activity = self.lastUserActivity()
+        if user_activity:
+            props = self.detectCollision(user_activity, self.lastNodeActivity())
+            if props:
+                self.handleCollision(props)
+                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))
+        except (ValueError, KeyError, IndexError,
+                roundup.exceptions.Reject), message:
+            self.client.error_message.append(
+                self._('Edit Error: %s') % str(message))
             return
 
         # commit now that all the tricky stuff is done
@@ -486,40 +636,45 @@ class EditItemAction(_EditAction):
         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
+            req = templating.HTMLRequest(self.client)
+            url += '&' + req.indexargs_url('', {})[1:]
+        raise exceptions.Redirect, url
 
-class NewItemAction(_EditAction):
+class NewItemAction(EditCommon):
     def handle(self):
         ''' Add a new item to the database.
 
             This follows the same form as the EditItemAction, with the same
             special form values.
         '''
+        # ensure modification comes via POST
+        if self.client.env['REQUEST_METHOD'] != 'POST':
+            raise roundup.exceptions.Reject(self._('Invalid request'))
+
         # 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))
+            self.client.error_message.append(self._('Error: %s')
+                % 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:
+        except (ValueError, KeyError, IndexError,
+                roundup.exceptions.Reject), message:
             # these errors might just be indicative of user dumbness
-            self.client.error_message.append(_('Error: ') + str(message))
+            self.client.error_message.append(_('Error: %s') % 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),
+        raise exceptions.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):
@@ -530,21 +685,23 @@ class PassResetAction(Action):
         "otk" performs the reset.
 
         """
+        otks = self.db.getOTKManager()
         if self.form.has_key('otk'):
             # pull the rego information out of the otk database
             otk = self.form['otk'].value
-            otks = self.db.getOTKManager()
-            uid = otks.get(otk, 'uid')
+            uid = otks.get(otk, 'uid', default=None)
             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)""")
+                self.client.error_message.append(
+                    self._("Invalid One Time Key!\n"
+                        "(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
+                otks = self.db.getOTKManager()
 
             # change the password
             newpw = password.generatePassword()
@@ -577,7 +734,7 @@ Your password is now: %(password)s
                 return
 
             self.client.ok_message.append(
-                    'Password reset and email sent to %s'%address)
+                self._('Password reset and email sent to %s') % address)
             return
 
         # no OTK, so now figure the user
@@ -586,19 +743,20 @@ Your password is now: %(password)s
             try:
                 uid = self.db.user.lookup(name)
             except KeyError:
-                self.client.error_message.append('Unknown username')
+                self.client.error_message.append(self._('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')
+                self.client.error_message.append(
+                    self._('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')
+            self.client.error_message.append(
+                self._('You need to specify a username or address'))
             return
 
         # generate the one-time-key and store the props for later
@@ -623,33 +781,21 @@ You should then receive another email with the new password.
         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:
-            self.client.error_message.append(str(message))
-            return
+        self.client.ok_message.append(self._('Email sent to %s') % address)
 
+class RegoCommon(Action):
+    def finishRego(self):
         # log the new user in
-        self.client.user = self.db.user.get(self.userid, 'username')
+        self.client.userid = self.userid
+        user = 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.client.opendb(user)
 
-        # if we have a session, update it
-        if hasattr(self, 'session'):
-            self.client.db.sessions.set(self.session, user=self.user,
-                last_use=time.time())
-        else:
-            # new session cookie
-            self.client.set_cookie(self.user)
+        # update session data
+        self.client.session_api.set(user=user)
 
         # nice message
-        message = _('You are now registered, welcome!')
+        message = self._('You are now registered, welcome!')
         url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
             urllib.quote(message))
 
@@ -661,54 +807,92 @@ class ConfRegoAction(Action):
             window.setTimeout('window.location = "%s"', 1000);
             </script>'''%(message, url, message, url)
 
-class RegisterAction(Action):
+class ConfRegoAction(RegoCommon):
+    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:
+            self.client.error_message.append(str(message))
+            return
+        return self.finishRego()
+
+class RegisterAction(RegoCommon, EditCommon):
     name = 'register'
-    permissionType = 'Web Registration'
+    permissionType = 'Register'
 
     def handle(self):
         """Attempt to create a new user based on the contents of the form
-        and then set the cookie.
+        and then remember it in session.
 
         Return 1 on successful login.
         """
-        props = self.client.parsePropsFromForm(create=1)[0][('user', None)]
+        # ensure modification comes via POST
+        if self.client.env['REQUEST_METHOD'] != 'POST':
+            raise roundup.exceptions.Reject(self._('Invalid request'))
 
-        # 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']
+        # parse the props from the form
         try:
-            self.db.user.lookup(username)
-            self.client.error_message.append(_('Error: A user with the '
-                'username "%(username)s" already exists')%props)
+            props, links = self.client.parsePropsFromForm(create=1)
+        except (ValueError, KeyError), message:
+            self.client.error_message.append(self._('Error: %s')
+                % str(message))
             return
-        except KeyError:
-            pass
+
+        # registration isn't allowed to supply roles
+        user_props = props[('user', None)]
+        if user_props.has_key('roles'):
+            raise exceptions.Unauthorised, self._(
+                "It is not permitted to supply roles at registration.")
+
+        # skip the confirmation step?
+        if self.db.config['INSTANT_REGISTRATION']:
+            # handle the create now
+            try:
+                # when it hits the None element, it'll set self.nodeid
+                messages = self._editnodes(props, links)
+            except (ValueError, KeyError, IndexError,
+                    roundup.exceptions.Reject), message:
+                # these errors might just be indicative of user dumbness
+                self.client.error_message.append(_('Error: %s') % str(message))
+                return
+
+            # fix up the initial roles
+            self.db.user.set(self.nodeid,
+                roles=self.db.config['NEW_WEB_USER_ROLES'])
+
+            # commit now that all the tricky stuff is done
+            self.db.commit()
+
+            # finish off by logging the user in
+            self.userid = self.nodeid
+            return self.finishRego()
 
         # generate the one-time-key and store the props for later
         for propname, proptype in self.db.user.getprops().items():
-            value = props.get(propname, None)
+            value = user_props.get(propname, None)
             if value is None:
                 pass
             elif isinstance(proptype, hyperdb.Date):
-                props[propname] = str(value)
+                user_props[propname] = str(value)
             elif isinstance(proptype, hyperdb.Interval):
-                props[propname] = str(value)
+                user_props[propname] = str(value)
             elif isinstance(proptype, hyperdb.Password):
-                props[propname] = str(value)
+                user_props[propname] = str(value)
         otks = self.db.getOTKManager()
+        otk = ''.join([random.choice(chars) for x in range(32)])
         while otks.exists(otk):
             otk = ''.join([random.choice(chars) for x in range(32)])
-        otks.set(otk, **props)
+        otks.set(otk, **user_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,
+        if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
+            subject = 'Complete your registration to %s -- key %s'%(tracker_name,
                                                                   otk)
-        body = """To complete your registration of the user "%(name)s" with
+            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
@@ -717,32 +901,43 @@ 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):
+
+""" % {'name': user_props['username'], 'tracker': tracker_name,
+        'url': self.base, 'otk': otk, 'tracker_email': tracker_email}
+        else:
+            subject = 'Complete your registration to %s'%(tracker_name)
+            body = """To complete your registration of the user "%(name)s" with
+%(tracker)s, please visit the following URL:
+
+%(url)s?@action=confrego&otk=%(otk)s
+
+""" % {'name': user_props['username'], 'tracker': tracker_name,
+        'url': self.base, 'otk': otk}
+        if not self.client.standard_message([user_props['address']], subject,
+                body, (tracker_name, 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
+        raise exceptions.Redirect, '%suser?@template=rego_progress'%self.base
 
 class LogoutAction(Action):
     def handle(self):
-        """Make us really anonymous - nuke the cookie too."""
+        """Make us really anonymous - nuke the session 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)
+        self.client.session_api.destroy()
 
         # Let the user know what's going on
-        self.client.ok_message.append(_('You are logged out'))
+        self.client.ok_message.append(self._('You are logged out'))
+
+        # reset client context to render tracker home page
+        # instead of last viewed page (may be inaccessibe for anonymous)
+        self.client.classname = None
+        self.client.nodeid = None
+        self.client.template = None
 
 class LoginAction(Action):
     def handle(self):
@@ -751,9 +946,13 @@ class LoginAction(Action):
         Sets up a session for the user which contains the login credentials.
 
         """
+        # ensure modification comes via POST
+        if self.client.env['REQUEST_METHOD'] != 'POST':
+            raise roundup.exceptions.Reject(self._('Invalid request'))
+
         # we need the username at a minimum
         if not self.form.has_key('__login_name'):
-            self.client.error_message.append(_('Username required'))
+            self.client.error_message.append(self._('Username required'))
             return
 
         # get the login info
@@ -763,40 +962,146 @@ class LoginAction(Action):
         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.verifyLogin(self.client.user, password)
+        except exceptions.LoginError, err:
             self.client.make_user_anonymous()
+            self.client.error_message.extend(list(err.args))
             return
 
+        # now we're OK, re-open the database for real, using the user
+        self.client.opendb(self.client.user)
+
+        # save user in session
+        self.client.session_api.set(user=self.client.user)
+        if self.form.has_key('remember'):
+            self.client.session_api.update(set_cookie=True, expire=24*3600*365)
+
+        # If we came from someplace, go back there
+        if self.form.has_key('__came_from'):
+            raise exceptions.Redirect, self.form['__came_from'].value
+
+    def verifyLogin(self, username, password):
+        # make sure the user exists
+        try:
+            self.client.userid = self.db.user.lookup(username)
+        except KeyError:
+            raise exceptions.LoginError, self._('Invalid login')
+
         # verify the password
         if not self.verifyPassword(self.client.userid, password):
-            self.client.make_user_anonymous()
-            self.client.error_message.append(_('Incorrect password'))
-            return
+            raise exceptions.LoginError, self._('Invalid login')
 
         # 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
-
-        # 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)
+            raise exceptions.LoginError, self._(
+                "You do not have permission to login")
 
     def verifyPassword(self, userid, password):
-        ''' Verify the password that the user has supplied
-        '''
-        stored = self.db.user.get(self.client.userid, 'password')
+        '''Verify the password that the user has supplied'''
+        stored = self.db.user.get(userid, 'password')
         if password == stored:
             return 1
         if not password and not stored:
             return 1
         return 0
+
+class ExportCSVAction(Action):
+    name = 'export'
+    permissionType = 'View'
+
+    def handle(self):
+        ''' Export the specified search query as CSV. '''
+        # figure the request
+        request = templating.HTMLRequest(self.client)
+        filterspec = request.filterspec
+        sort = request.sort
+        group = request.group
+        columns = request.columns
+        klass = self.db.getclass(request.classname)
+
+        # full-text search
+        if request.search_text:
+            matches = self.db.indexer.search(
+                re.findall(r'\b\w{2,25}\b', request.search_text), klass)
+        else:
+            matches = None
+
+        h = self.client.additional_headers
+        h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
+        # some browsers will honor the filename here...
+        h['Content-Disposition'] = 'inline; filename=query.csv'
+
+        self.client.header()
+
+        if self.client.env['REQUEST_METHOD'] == 'HEAD':
+            # all done, return a dummy string
+            return 'dummy'
+
+        wfile = self.client.request.wfile
+        if self.client.charset != self.client.STORAGE_CHARSET:
+            wfile = codecs.EncodedFile(wfile,
+                self.client.STORAGE_CHARSET, self.client.charset, 'replace')
+
+        writer = csv.writer(wfile)
+        self.client._socket_op(writer.writerow, columns)
+
+        # and search
+        for itemid in klass.filter(matches, filterspec, sort, group):
+            row = []
+            for name in columns:
+                # check permission to view this property on this item
+                if not self.hasPermission('View', itemid=itemid,
+                        classname=request.classname, property=name):
+                    raise exceptions.Unauthorised, self._(
+                        'You do not have permission to view %(class)s'
+                    ) % {'class': request.classname}
+                row.append(str(klass.get(itemid, name)))
+            self.client._socket_op(writer.writerow, row)
+
+        return '\n'
+
+
+class Bridge(BaseAction):
+    """Make roundup.actions.Action executable via CGI request.
+
+    Using this allows users to write actions executable from multiple frontends.
+    CGI Form content is translated into a dictionary, which then is passed as
+    argument to 'handle()'. XMLRPC requests have to pass this dictionary
+    directly.
+    """
+
+    def __init__(self, *args):
+
+        # As this constructor is callable from multiple frontends, each with
+        # different Action interfaces, we have to look at the arguments to
+        # figure out how to complete construction.
+        if (len(args) == 1 and
+            hasattr(args[0], '__class__') and
+            args[0].__class__.__name__ == 'Client'):
+            self.cgi = True
+            self.execute = self.execute_cgi
+            self.client = args[0]
+            self.form = self.client.form
+        else:
+            self.cgi = False
+
+    def execute_cgi(self):
+        args = {}
+        for key in self.form.keys():
+            args[key] = self.form.getvalue(key)
+        self.permission(args)
+        return self.handle(args)
+
+    def permission(self, args):
+        """Raise Unauthorised if the current user is not allowed to execute
+        this action. Users may override this method."""
+
+        pass
+
+    def handle(self, args):
+
+        raise NotImplementedError
+
+# vim: set filetype=python sts=4 sw=4 et si :