Code

Added a Zope frontend for roundup.
[roundup.git] / roundup / cgi_client.py
index 478cda0c3ccfd1820da202f6438cd9741b37f1f7..59b587b33aff973c225554a5cf5761a9796485bf 100644 (file)
@@ -15,7 +15,7 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: cgi_client.py,v 1.70 2001-11-30 00:06:29 richard Exp $
+# $Id: cgi_client.py,v 1.80 2001-12-12 23:27:14 richard Exp $
 
 __doc__ = """
 WWW request handler (also used in the stand-alone server).
@@ -60,14 +60,17 @@ class Client:
     ANONYMOUS_ACCESS = 'deny'        # one of 'deny', 'allow'
     ANONYMOUS_REGISTER = 'deny'      # one of 'deny', 'allow'
 
-    def __init__(self, instance, request, env):
+    def __init__(self, instance, request, env, form=None):
         self.instance = instance
         self.request = request
         self.env = env
         self.path = env['PATH_INFO']
         self.split_path = self.path.split('/')
 
-        self.form = cgi.FieldStorage(environ=env)
+        if form is None:
+            self.form = cgi.FieldStorage(environ=env)
+        else:
+            self.form = form
         self.headers_done = 0
         try:
             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
@@ -111,7 +114,7 @@ class Client:
         if self.user not in (None, 'anonymous'):
             userid = self.db.user.lookup(self.user)
             user_info = _('''
-<a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
+<a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
 ''')%locals()
         else:
@@ -135,9 +138,9 @@ class Client:
 <td align=right valign=bottom>%(user_name)s</td></tr>
 <tr class="location-bar">
 <td align=left>All
-<a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
+<a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
 | Unassigned
-<a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
+<a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>
 %(add_links)s
 %(admin_links)s</td>
 <td align=right>%(user_info)s</td>
@@ -313,7 +316,7 @@ class Client:
                     self.nodeid)
 
                 # set status to chatting if 'unread' or 'resolved'
-                if 'status' not in changed:
+                if not changed.has_key('status'):
                     try:
                         # determine the id of 'unread','resolved' and 'chatting'
                         unread_id = self.db.status.lookup('unread')
@@ -326,20 +329,27 @@ class Client:
                                 props['status'] == unread_id or
                                 props['status'] == resolved_id):
                             props['status'] = chatting_id
-                            changed.append('status')
-                note = None
-                if self.form.has_key('__note'):
-                    note = self.form['__note']
-                    note = note.value
-                if changed or note:
-                    cl.set(self.nodeid, **props)
-                    self._post_editnode(self.nodeid, changed)
-                    # and some nice feedback for the user
+                            changed['status'] = chatting_id
+
+                # get the change note
+                change_note = cl.generateChangeNote(self.nodeid, changed)
+
+                # make the changes
+                cl.set(self.nodeid, **props)
+
+                # handle linked nodes and change message generation
+                self._post_editnode(self.nodeid, change_note)
+
+                # and some nice feedback for the user
+                if changed:
                     message = _('%(changes)s edited ok')%{'changes':
-                        ', '.join(changed)}
+                        ', '.join(changed.keys())}
+                elif self.form.has_key('__note') and self.form['__note'].value:
+                    message = _('note added')
                 else:
                     message = _('nothing changed')
             except:
+                self.db.rollback()
                 s = StringIO.StringIO()
                 traceback.print_exc(None, s)
                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
@@ -393,11 +403,12 @@ class Client:
                         del props['password']
                         del changed[changed.index('password')]
                 user.set(self.nodeid, **props)
-                self._post_editnode(self.nodeid, changed)
+                self._post_editnode(self.nodeid)
                 # and some feedback for the user
                 message = _('%(changes)s edited ok')%{'changes':
-                    ', '.join(changed)}
+                    ', '.join(changed.keys())}
             except:
+                self.db.rollback()
                 s = StringIO.StringIO()
                 traceback.print_exc(None, s)
                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
@@ -434,9 +445,19 @@ class Client:
         '''
         cl = self.db.classes[self.classname]
         props, dummy = parsePropsFromForm(self.db, cl, self.form)
+
+        # set status to 'unread' if not specified - a status of '- no
+        # selection -' doesn't make sense
+        if not props.has_key('status'):
+            try:
+                unread_id = self.db.status.lookup('unread')
+            except KeyError:
+                pass
+            else:
+                props['status'] = unread_id
         return cl.create(**props)
 
-    def _post_editnode(self, nid, changes=None):
+    def _post_editnode(self, nid, change_note=''):
         ''' do the linking and message sending part of the node creation
         '''
         cn = self.classname
@@ -464,7 +485,7 @@ class Client:
                     link.set(nodeid, **{property: nid})
 
         # handle file attachments
-        files = []
+        files = cl.get(nid, 'files')
         if self.form.has_key('__file'):
             file = self.form['__file']
             if file.filename:
@@ -474,68 +495,53 @@ class Client:
                 # create the new file entry
                 files.append(self.db.file.create(type=mime_type,
                     name=file.filename, content=file.file.read()))
+                # and save the reference
+                cl.set(nid, files=files)
 
+        #
         # generate an edit message
-        # don't bother if there's no messages or nosy list 
+        #
+
+        # we don't want to do a message if none of the following is true...
         props = cl.getprops()
         note = None
         if self.form.has_key('__note'):
-            note = self.form['__note']
-            note = note.value
-        send = len(cl.get(nid, 'nosy', [])) or note
-        if (send and props.has_key('messages') and
-                isinstance(props['messages'], hyperdb.Multilink) and
-                props['messages'].classname == 'msg'):
-
-            # handle the note
-            if note:
-                if '\n' in note:
-                    summary = re.split(r'\n\r?', note)[0]
-                else:
-                    summary = note
-                m = ['%s\n'%note]
+            note = self.form['__note'].value
+        if not props.has_key('messages'):
+            return
+        if not isinstance(props['messages'], hyperdb.Multilink):
+            return
+        if not props['messages'].classname == 'msg':
+            return
+        if not (len(cl.get(nid, 'nosy', [])) or note):
+            return
+
+        # handle the note
+        if note:
+            if '\n' in note:
+                summary = re.split(r'\n\r?', note)[0]
             else:
-                summary = _('This %(classname)s has been edited through'
-                    ' the web.\n')%{'classname': cn}
-                m = [summary]
-
-            # figure the changes and add them to the message
-            first = 1
-            for name, prop in props.items():
-                if changes is not None and name not in changes: continue
-                if first:
-                    m.append('\n-------')
-                    first = 0
-                value = cl.get(nid, name, None)
-                if isinstance(prop, hyperdb.Link):
-                    link = self.db.classes[prop.classname]
-                    key = link.labelprop(default_to_id=1)
-                    if value is not None and key:
-                        value = link.get(value, key)
-                    else:
-                        value = '-'
-                elif isinstance(prop, hyperdb.Multilink):
-                    if value is None: value = []
-                    l = []
-                    link = self.db.classes[prop.classname]
-                    key = link.labelprop(default_to_id=1)
-                    for entry in value:
-                        if key:
-                            l.append(link.get(entry, key))
-                        else:
-                            l.append(entry)
-                    value = ', '.join(l)
-                m.append('%s: %s'%(name, value))
-
-            # now create the message
-            content = '\n'.join(m)
-            message_id = self.db.msg.create(author=self.getuid(),
-                recipients=[], date=date.Date('.'), summary=summary,
-                content=content, files=files)
-            messages = cl.get(nid, 'messages')
-            messages.append(message_id)
-            props = {'messages': messages, 'files': files}
-            cl.set(nid, **props)
+                summary = note
+            m = ['%s\n'%note]
+        else:
+            summary = _('This %(classname)s has been edited through'
+                ' the web.\n')%{'classname': cn}
+            m = [summary]
+
+        # append the change note
+        if change_note:
+            m.append(change_note)
+
+        # now create the message
+        content = '\n'.join(m)
+        message_id = self.db.msg.create(author=self.getuid(),
+            recipients=[], date=date.Date('.'), summary=summary,
+            content=content, files=files)
+
+        # update the messages property
+        messages = cl.get(nid, 'messages')
+        messages.append(message_id)
+        cl.set(nid, messages=messages, files=files)
 
     def newnode(self, message=None):
         ''' Add a new node to the database.
@@ -568,10 +574,12 @@ class Client:
             props = {}
             try:
                 nid = self._createnode()
+                # handle linked nodes and change message generation
                 self._post_editnode(nid)
                 # and some nice feedback for the user
                 message = _('%(classname)s created ok')%{'classname': cn}
             except:
+                self.db.rollback()
                 s = StringIO.StringIO()
                 traceback.print_exc(None, s)
                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
@@ -604,11 +612,15 @@ class Client:
                 mime_type = mimetypes.guess_type(file.filename)[0]
                 if not mime_type:
                     mime_type = "application/octet-stream"
-                self._post_editnode(cl.create(content=file.file.read(),
-                    type=mime_type, name=file.filename))
+                # save the file
+                nid = cl.create(content=file.file.read(), type=mime_type,
+                    name=file.filename)
+                # handle linked nodes
+                self._post_editnode(nid)
                 # and some nice feedback for the user
                 message = _('%(classname)s created ok')%{'classname': cn}
             except:
+                self.db.rollback()
                 s = StringIO.StringIO()
                 traceback.print_exc(None, s)
                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
@@ -642,6 +654,8 @@ class Client:
             raise Unauthorised
 
     def login(self, message=None, newuser_form=None, action='index'):
+        '''Display a login page.
+        '''
         self.pagehead(_('Login to roundup'), message)
         self.write(_('''
 <table>
@@ -661,7 +675,8 @@ class Client:
             self.pagefoot()
             return
         values = {'realname': '', 'organisation': '', 'address': '',
-            'phone': '', 'username': '', 'password': '', 'confirm': ''}
+            'phone': '', 'username': '', 'password': '', 'confirm': '',
+            'action': action}
         if newuser_form is not None:
             for key in newuser_form.keys():
                 values[key] = newuser_form[key].value
@@ -670,6 +685,7 @@ class Client:
 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
 <form action="newuser_action" method=POST>
+<input type="hidden" name="__destination_url" value="%(action)s">
 <tr><td align=right><em>Name: </em></td>
     <td><input name="realname" value="%(realname)s"></td></tr>
 <tr><td align=right><em>Organisation: </em></td>
@@ -692,8 +708,14 @@ class Client:
         self.pagefoot()
 
     def login_action(self, message=None):
+        '''Attempt to log a user in and set the cookie
+
+        returns 0 if a page is generated as a result of this call, and
+        1 if not (ie. the login is successful
+        '''
         if not self.form.has_key('__login_name'):
-            return self.login(message=_('Username required'))
+            self.login(message=_('Username required'))
+            return 0
         self.user = self.form['__login_name'].value
         if self.form.has_key('__login_password'):
             password = self.form['__login_password'].value
@@ -705,16 +727,44 @@ class Client:
         except KeyError:
             name = self.user
             self.make_user_anonymous()
-            return self.login(message=_('No such user "%(name)s"')%locals())
+            action = self.form['__destination_url'].value
+            self.login(message=_('No such user "%(name)s"')%locals(),
+                action=action)
+            return 0
 
         # and that the password is correct
         pw = self.db.user.get(uid, 'password')
-        if password != self.db.user.get(uid, 'password'):
+        if password != pw:
             self.make_user_anonymous()
-            return self.login(message=_('Incorrect password'))
+            action = self.form['__destination_url'].value
+            self.login(message=_('Incorrect password'), action=action)
+            return 0
 
         self.set_cookie(self.user, password)
-        return None     # make it explicit
+        return 1
+
+    def newuser_action(self, message=None):
+        '''Attempt to create a new user based on the contents of the form
+        and then set the cookie.
+
+        return 1 on successful login
+        '''
+        # re-open the database as "admin"
+        self.db = self.instance.open('admin')
+
+        # TODO: pre-check the required fields and username key property
+        cl = self.db.user
+        try:
+            props, dummy = parsePropsFromForm(self.db, cl, self.form)
+            uid = cl.create(**props)
+        except ValueError, message:
+            action = self.form['__destination_url'].value
+            self.login(message, action=action)
+            return 0
+        self.user = cl.get(uid, 'username')
+        password = cl.get(uid, 'password')
+        self.set_cookie(self.user, self.form['password'].value)
+        return 1
 
     def set_cookie(self, user, password):
         # construct the cookie
@@ -745,29 +795,12 @@ class Client:
         self.header({'Set-Cookie':
             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
             path)})
-        return self.login()
+        self.login()
 
-    def newuser_action(self, message=None):
-        ''' create a new user based on the contents of the form and then
-        set the cookie
-        '''
-        # re-open the database as "admin"
-        self.db.close()
-        self.db = self.instance.open('admin')
-
-        # TODO: pre-check the required fields and username key property
-        cl = self.db.user
-        try:
-            props, dummy = parsePropsFromForm(self.db, cl, self.form)
-            uid = cl.create(**props)
-        except ValueError, message:
-            return self.login(message, newuser_form=self.form)
-        self.user = cl.get(uid, 'username')
-        password = cl.get(uid, 'password')
-        self.set_cookie(self.user, self.form['password'].value)
-        return None    # make the None explicit
 
     def main(self):
+        '''Wrap the database accesses so we can close the database cleanly
+        '''
         # determine the uid to use
         self.db = self.instance.open('admin')
         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
@@ -797,7 +830,6 @@ class Client:
             self.make_user_anonymous()
         else:
             self.user = user
-        self.db.close()
 
         # re-open the database for real, using the user
         self.db = self.instance.open(self.user)
@@ -818,15 +850,15 @@ class Client:
 
         # everyone is allowed to try to log in
         if action == 'login_action':
-            # do the login
-            ret = self.login_action()
-            if ret is not None:
-                return ret
+            # try to login
+            if not self.login_action():
+                return
             # figure the resulting page
             action = self.form['__destination_url'].value
             if not action:
                 action = 'index'
-            return self.do_action(action)
+            self.do_action(action)
+            return
 
         # allow anonymous people to register
         if action == 'newuser_action':
@@ -834,40 +866,49 @@ class Client:
             # register, then spit up the login form
             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
                 if action == 'login':
-                    return self.login()         # go to the index after login
+                    self.login()         # go to the index after login
                 else:
-                    return self.login(action=action)
-            # add the user
-            ret = self.newuser_action()
-            if ret is not None:
-                return ret
+                    self.login(action=action)
+                return
+            # try to add the user
+            if not self.newuser_action():
+                return
             # figure the resulting page
             action = self.form['__destination_url'].value
             if not action:
                 action = 'index'
-            return self.do_action(action)
 
         # no login or registration, make sure totally anonymous access is OK
-        if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
+        elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
             if action == 'login':
-                return self.login()             # go to the index after login
+                self.login()             # go to the index after login
             else:
-                return self.login(action=action)
+                self.login(action=action)
+            return
 
         # just a regular action
-        return self.do_action(action)
+        self.do_action(action)
+
+        # commit all changes to the database
+        self.db.commit()
 
     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
             nre=re.compile(r'new(\w+)')):
+        '''Figure the user's action and do it.
+        '''
         # here be the "normal" functionality
         if action == 'index':
-            return self.index()
+            self.index()
+            return
         if action == 'list_classes':
-            return self.classes()
+            self.classes()
+            return
         if action == 'login':
-            return self.login()
+            self.login()
+            return
         if action == 'logout':
-            return self.logout()
+            self.logout()
+            return
         m = dre.match(action)
         if m:
             self.classname = m.group(1)
@@ -884,7 +925,8 @@ class Client:
                 func = getattr(self, 'show%s'%self.classname)
             except AttributeError:
                 raise NotFound
-            return func()
+            func()
+            return
         m = nre.match(action)
         if m:
             self.classname = m.group(1)
@@ -892,16 +934,14 @@ class Client:
                 func = getattr(self, 'new%s'%self.classname)
             except AttributeError:
                 raise NotFound
-            return func()
+            func()
+            return
         self.classname = action
         try:
             self.db.getclass(self.classname)
         except KeyError:
             raise NotFound
-        return self.list()
-
-    def __del__(self):
-        self.db.close()
+        self.list()
 
 
 class ExtendedClient(Client): 
@@ -939,8 +979,8 @@ class ExtendedClient(Client):
         if self.user not in (None, 'anonymous'):
             userid = self.db.user.lookup(self.user)
             user_info = _('''
-<a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
-<a href="support?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=activity&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">My Support</a> |
+<a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
+<a href="support?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">My Support</a> |
 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
 ''')%locals()
         else:
@@ -968,8 +1008,8 @@ class ExtendedClient(Client):
 <a href="issue?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
 <a href="support?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
 | Unassigned
-<a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
-<a href="support?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
+<a href="issue?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">Issues</a>,
+<a href="support?assignedto=-1&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=-activity&:filter=status,assignedto&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
 %(add_links)s
 %(admin_links)s</td>
 <td align=right>%(user_info)s</td>
@@ -980,7 +1020,7 @@ def parsePropsFromForm(db, cl, form, nodeid=0):
     '''Pull properties for the given class out of the form.
     '''
     props = {}
-    changed = []
+    changed = {}
     keys = form.keys()
     num_re = re.compile('^\d+$')
     for key in keys:
@@ -1020,6 +1060,7 @@ def parsePropsFromForm(db, cl, form, nodeid=0):
             link = cl.properties[key].classname
             l = []
             for entry in map(str, value):
+                if entry == '': continue
                 if not num_re.match(entry):
                     try:
                         entry = db.classes[link].lookup(entry)
@@ -1043,12 +1084,72 @@ def parsePropsFromForm(db, cl, form, nodeid=0):
 
         # if changed, set it
         if nodeid and value != existing:
-            changed.append(key)
+            changed[key] = value
             props[key] = value
     return props, changed
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.79  2001/12/10 22:20:01  richard
+# Enabled transaction support in the bsddb backend. It uses the anydbm code
+# where possible, only replacing methods where the db is opened (it uses the
+# btree opener specifically.)
+# Also cleaned up some change note generation.
+# Made the backends package work with pydoc too.
+#
+# Revision 1.78  2001/12/07 05:59:27  rochecompaan
+# Fixed small bug that prevented adding issues through the web.
+#
+# Revision 1.77  2001/12/06 22:48:29  richard
+# files multilink was being nuked in post_edit_node
+#
+# Revision 1.76  2001/12/05 14:26:44  rochecompaan
+# Removed generation of change note from "sendmessage" in roundupdb.py.
+# The change note is now generated when the message is created.
+#
+# Revision 1.75  2001/12/04 01:25:08  richard
+# Added some rollbacks where we were catching exceptions that would otherwise
+# have stopped committing.
+#
+# Revision 1.74  2001/12/02 05:06:16  richard
+# . We now use weakrefs in the Classes to keep the database reference, so
+#   the close() method on the database is no longer needed.
+#   I bumped the minimum python requirement up to 2.1 accordingly.
+# . #487480 ] roundup-server
+# . #487476 ] INSTALL.txt
+#
+# I also cleaned up the change message / post-edit stuff in the cgi client.
+# There's now a clearly marked "TODO: append the change note" where I believe
+# the change note should be added there. The "changes" list will obviously
+# have to be modified to be a dict of the changes, or somesuch.
+#
+# More testing needed.
+#
+# Revision 1.73  2001/12/01 07:17:50  richard
+# . We now have basic transaction support! Information is only written to
+#   the database when the commit() method is called. Only the anydbm
+#   backend is modified in this way - neither of the bsddb backends have been.
+#   The mail, admin and cgi interfaces all use commit (except the admin tool
+#   doesn't have a commit command, so interactive users can't commit...)
+# . Fixed login/registration forwarding the user to the right page (or not,
+#   on a failure)
+#
+# Revision 1.72  2001/11/30 20:47:58  rochecompaan
+# Links in page header are now consistent with default sort order.
+#
+# Fixed bugs:
+#     - When login failed the list of issues were still rendered.
+#     - User was redirected to index page and not to his destination url
+#       if his first login attempt failed.
+#
+# Revision 1.71  2001/11/30 20:28:10  rochecompaan
+# Property changes are now completely traceable, whether changes are
+# made through the web or by email
+#
+# Revision 1.70  2001/11/30 00:06:29  richard
+# Converted roundup/cgi_client.py to use _()
+# Added the status file, I18N_PROGRESS.txt
+#
 # Revision 1.69  2001/11/29 23:19:51  richard
 # Removed the "This issue has been edited through the web" when a valid
 # change note is supplied.