Code

Some cleanup.
[roundup.git] / roundup / cgi_client.py
index 33d2381fcc9f64840f1abee93563bdc19f3550a7..c978b3d94fcd147abdbb582819b0b793b9fba0d7 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: cgi_client.py,v 1.27 2001-10-05 02:23:24 richard Exp $
+# $Id: cgi_client.py,v 1.40 2001-10-23 23:06:39 richard Exp $
 
 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
 import base64, Cookie, time
 
-import roundupdb, htmltemplate, date, hyperdb
+import roundupdb, htmltemplate, date, hyperdb, password
 
 class Unauthorised(ValueError):
     pass
@@ -30,7 +30,6 @@ class NotFound(ValueError):
 
 class Client:
     '''
-
     A note about login
     ------------------
 
@@ -40,7 +39,18 @@ class Client:
     'anonymous' user exists, the user is logged in using that user (though
     there is no cookie). This allows them to modify the database, and all
     modifications are attributed to the 'anonymous' user.
+
+
+    Customisation
+    -------------
+      FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
+      ANONYMOUS_ACCESS - one of 'deny', 'allow'
+      ANONYMOUS_REGISTER - one of 'deny', 'allow'
+
     '''
+    FILTER_POSITION = 'bottom'       # one of 'top', 'bottom', 'top and bottom'
+    ANONYMOUS_ACCESS = 'deny'        # one of 'deny', 'allow'
+    ANONYMOUS_REGISTER = 'deny'      # one of 'deny', 'allow'
 
     def __init__(self, instance, out, env):
         self.instance = instance
@@ -130,7 +140,7 @@ class Client:
             return arg.value.split(',')
         return []
 
-    def index_filterspec(self):
+    def index_filterspec(self, filter):
         ''' pull the index filter spec from the form
 
         Links and multilinks want to be lists - the rest are straight
@@ -142,6 +152,7 @@ class Client:
         for key in self.form.keys():
             if key[0] == ':': continue
             if not props.has_key(key): continue
+            if key not in filter: continue
             prop = props[key]
             value = self.form[key]
             if (isinstance(prop, hyperdb.Link) or
@@ -157,33 +168,56 @@ class Client:
                 filterspec[key] = value.value
         return filterspec
 
+    def customization_widget(self):
+        ''' The customization widget is visible by default. The widget
+            visibility is remembered by show_customization.  Visibility
+            is not toggled if the action value is "Redisplay"
+        '''
+        if not self.form.has_key('show_customization'):
+            visible = 1
+        else:
+            visible = int(self.form['show_customization'].value)
+            if self.form.has_key('action'):
+                if self.form['action'].value != 'Redisplay':
+                    visible = self.form['action'].value == '+'
+            
+        return visible
+
     default_index_sort = ['-activity']
     default_index_group = ['priority']
-    default_index_filter = []
+    default_index_filter = ['status']
     default_index_columns = ['id','activity','title','status','assignedto']
     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
     def index(self):
         ''' put up an index
         '''
         self.classname = 'issue'
-        if self.form.has_key(':sort'): sort = self.index_arg(':sort')
-        else: sort = self.default_index_sort
-        if self.form.has_key(':group'): group = self.index_arg(':group')
-        else: group = self.default_index_group
-        if self.form.has_key(':filter'): filter = self.index_arg(':filter')
-        else: filter = self.default_index_filter
-        if self.form.has_key(':columns'): columns = self.index_arg(':columns')
-        else: columns = self.default_index_columns
-        filterspec = self.index_filterspec()
-        if not filterspec:
+        # see if the web has supplied us with any customisation info
+        defaults = 1
+        for key in ':sort', ':group', ':filter', ':columns':
+            if self.form.has_key(key):
+                defaults = 0
+                break
+        if defaults:
+            # no info supplied - use the defaults
+            sort = self.default_index_sort
+            group = self.default_index_group
+            filter = self.default_index_filter
+            columns = self.default_index_columns
             filterspec = self.default_index_filterspec
+        else:
+            sort = self.index_arg(':sort')
+            group = self.index_arg(':group')
+            filter = self.index_arg(':filter')
+            columns = self.index_arg(':columns')
+            filterspec = self.index_filterspec(filter)
         return self.list(columns=columns, filter=filter, group=group,
             sort=sort, filterspec=filterspec)
 
     # XXX deviates from spec - loses the '+' (that's a reserved character
     # in URLS
     def list(self, sort=None, group=None, filter=None, columns=None,
-            filterspec=None):
+            filterspec=None, show_customization=None):
         ''' call the template index with the args
 
             :sort    - sort by prop name, optionally preceeded with '-'
@@ -202,10 +236,13 @@ class Client:
         if group is None: group = self.index_arg(':group')
         if filter is None: filter = self.index_arg(':filter')
         if columns is None: columns = self.index_arg(':columns')
-        if filterspec is None: filterspec = self.index_filterspec()
+        if filterspec is None: filterspec = self.index_filterspec(filter)
+        if show_customization is None:
+            show_customization = self.customization_widget()
 
-        htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
-            filter, columns, sort, group)
+        index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
+        index.render(filterspec, filter, columns, sort, group,
+            show_customization=show_customization)
         self.pagefoot()
 
     def shownode(self, message=None):
@@ -219,7 +256,8 @@ class Client:
         num_re = re.compile('^\d+$')
         if keys:
             try:
-                props, changed = parsePropsFromForm(cl, self.form, self.nodeid)
+                props, changed = parsePropsFromForm(self.db, cl, self.form,
+                    self.nodeid)
                 cl.set(self.nodeid, **props)
                 self._post_editnode(self.nodeid, changed)
                 # and some nice feedback for the user
@@ -238,7 +276,9 @@ class Client:
         nodeid = self.nodeid
 
         # use the template to display the item
-        htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
+        item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
+        item.render(nodeid)
+
         self.pagefoot()
     showissue = shownode
     showmsg = shownode
@@ -266,7 +306,7 @@ class Client:
         ''' create a node based on the contents of the form
         '''
         cl = self.db.classes[self.classname]
-        props, dummy = parsePropsFromForm(cl, self.form)
+        props, dummy = parsePropsFromForm(self.db, cl, self.form)
         return cl.create(**props)
 
     def _post_editnode(self, nid, changes=None):
@@ -340,7 +380,7 @@ class Client:
                     key = link.labelprop(default_to_id=1)
                     for entry in value:
                         if key:
-                            l.append(link.get(entry, link.getkey()))
+                            l.append(link.get(entry, key))
                         else:
                             l.append(entry)
                     value = ', '.join(l)
@@ -395,8 +435,12 @@ class Client:
                 traceback.print_exc(None, s)
                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
         self.pagehead('New %s'%self.classname.capitalize(), message)
-        htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
-            self.form)
+
+        # call the template
+        newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
+            self.classname)
+        newitem.render(self.form)
+
         self.pagefoot()
     newissue = newnode
     newuser = newnode
@@ -428,8 +472,9 @@ class Client:
                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
 
         self.pagehead('New %s'%self.classname.capitalize(), message)
-        htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
-            self.form)
+        newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
+            self.classname)
+        newitem.render(self.form)
         self.pagefoot()
 
     def classes(self, message=None):
@@ -466,7 +511,11 @@ class Client:
 <tr><td></td>
     <td><input type="submit" value="Log In"></td></tr>
 </form>
-
+''')
+        if self.user is None and not self.ANONYMOUS_REGISTER == 'deny':
+            self.write('</table')
+            return
+        self.write('''
 <p>
 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
@@ -492,8 +541,14 @@ class Client:
 ''')
 
     def login_action(self, message=None):
+        if not self.form.has_key('__login_name'):
+            return self.login(message='Username required')
         self.user = self.form['__login_name'].value
-        password = self.form['__login_password'].value
+        if self.form.has_key('__login_password'):
+            password = self.form['__login_password'].value
+        else:
+            password = ''
+        print self.user, password
         # make sure the user exists
         try:
             uid = self.db.user.lookup(self.user)
@@ -503,7 +558,9 @@ class Client:
             return self.login(message='No such user "%s"'%name)
 
         # and that the password is correct
+        pw = self.db.user.get(uid, 'password')
         if password != self.db.user.get(uid, 'password'):
+            self.make_user_anonymous()
             return self.login(message='Incorrect password')
 
         # construct the cookie
@@ -511,10 +568,7 @@ class Client:
         user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
             ''))
-        cookie = Cookie.SmartCookie()
-        cookie['roundup_user'] = user
-        cookie['roundup_user']['path'] = path
-        self.header({'Set-Cookie': str(cookie)})
+        self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
         return self.index()
 
     def make_user_anonymous(self):
@@ -530,21 +584,22 @@ class Client:
         # construct the logout cookie
         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
             ''))
-        cookie = Cookie.SmartCookie()
-        cookie['roundup_user'] = 'deleted'
-        cookie['roundup_user']['path'] = path
-        cookie['roundup_user']['expires'] = 0
-        cookie['roundup_user']['max-age'] = 0
-        self.header({'Set-Cookie': str(cookie)})
+        now = Cookie._getdate()
+        self.header({'Set-Cookie':
+            'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
         return self.index()
 
     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.classes['user']
-        props, dummy = parsePropsFromForm(cl, self.form)
+        props, dummy = parsePropsFromForm(self.db, cl, self.form)
         uid = cl.create(**props)
         self.user = self.db.user.get(uid, 'username')
         password = self.db.user.get(uid, 'password')
@@ -553,10 +608,7 @@ class Client:
         user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
             ''))
-        cookie = Cookie.SmartCookie()
-        cookie['roundup_user'] = user
-        cookie['roundup_user']['path'] = path
-        self.header({'Set-Cookie': str(cookie)})
+        self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
         return self.index()
 
     def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
@@ -592,61 +644,146 @@ class Client:
         # now figure which function to call
         path = self.split_path
         if not path or path[0] in ('', 'index'):
-            self.index()
-        elif len(path) == 1:
-            if path[0] == 'list_classes':
-                self.classes()
-                return
-            if path[0] == 'login':
-                self.login()
-                return
-            if path[0] == 'login_action':
-                self.login_action()
-                return
-            if path[0] == 'newuser_action':
-                self.newuser_action()
-                return
-            if path[0] == 'logout':
-                self.logout()
-                return
-            m = dre.match(path[0])
-            if m:
-                self.classname = m.group(1)
-                self.nodeid = m.group(2)
-                try:
-                    cl = self.db.classes[self.classname]
-                except KeyError:
-                    raise NotFound
-                try:
-                    cl.get(self.nodeid, 'id')
-                except IndexError:
-                    raise NotFound
-                try:
-                    getattr(self, 'show%s'%self.classname)()
-                except AttributeError:
-                    raise NotFound
-                return
-            m = nre.match(path[0])
-            if m:
-                self.classname = m.group(1)
-                try:
-                    getattr(self, 'new%s'%self.classname)()
-                except AttributeError:
-                    raise NotFound
-                return
-            self.classname = path[0]
+            return self.index()
+        elif not path:
+            raise 'ValueError', 'Path not understood'
+
+        #
+        # Everthing ignores path[1:]
+        #
+        # The file download link generator actually relies on this - it
+        # appends the name of the file to the URL so the download file name
+        # is correct, but doesn't actually use it.
+        action = path[0]
+        if action == 'login_action':
+            return self.login_action()
+
+        # make sure anonymous are allowed to register
+        if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
+            return self.login()
+
+        if action == 'newuser_action':
+            return self.newuser_action()
+
+        # make sure totally anonymous access is OK
+        if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
+            return self.login()
+
+        if action == 'list_classes':
+            return self.classes()
+        if action == 'login':
+            return self.login()
+        if action == 'logout':
+            return self.logout()
+        m = dre.match(action)
+        if m:
+            self.classname = m.group(1)
+            self.nodeid = m.group(2)
             try:
-                self.db.getclass(self.classname)
+                cl = self.db.classes[self.classname]
             except KeyError:
                 raise NotFound
-            self.list()
-        else:
-            raise 'ValueError', 'Path not understood'
+            try:
+                cl.get(self.nodeid, 'id')
+            except IndexError:
+                raise NotFound
+            try:
+                func = getattr(self, 'show%s'%self.classname)
+            except AttributeError:
+                raise NotFound
+            return func()
+        m = nre.match(action)
+        if m:
+            self.classname = m.group(1)
+            try:
+                func = getattr(self, 'new%s'%self.classname)
+            except AttributeError:
+                raise NotFound
+            return func()
+        self.classname = action
+        try:
+            self.db.getclass(self.classname)
+        except KeyError:
+            raise NotFound
+        self.list()
 
     def __del__(self):
         self.db.close()
 
-def parsePropsFromForm(cl, form, nodeid=0):
+
+class ExtendedClient(Client): 
+    '''Includes pages and page heading information that relate to the
+       extended schema.
+    ''' 
+    showsupport = Client.shownode
+    showtimelog = Client.shownode
+    newsupport = Client.newnode
+    newtimelog = Client.newnode
+
+    default_index_sort = ['-activity']
+    default_index_group = ['priority']
+    default_index_filter = ['status']
+    default_index_columns = ['activity','status','title','assignedto']
+    default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
+
+    def pagehead(self, title, message=None):
+        url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
+        machine = self.env['SERVER_NAME']
+        port = self.env['SERVER_PORT']
+        if port != '80': machine = machine + ':' + port
+        base = urlparse.urlunparse(('http', machine, url, None, None, None))
+        if message is not None:
+            message = '<div class="system-msg">%s</div>'%message
+        else:
+            message = ''
+        style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
+        user_name = self.user or ''
+        if self.user == 'admin':
+            admin_links = ' | <a href="list_classes">Class List</a>'
+        else:
+            admin_links = ''
+        if self.user not in (None, 'anonymous'):
+            userid = self.db.user.lookup(self.user)
+            user_info = '''
+<a href="issue?assignedto=%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=%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%s">My Details</a> | <a href="logout">Logout</a>
+'''%(userid, userid, userid)
+        else:
+            user_info = '<a href="login">Login</a>'
+        if self.user is not None:
+            add_links = '''
+| Add
+<a href="newissue">Issue</a>,
+<a href="newsupport">Support</a>,
+<a href="newuser">User</a>
+'''
+        else:
+            add_links = ''
+        self.write('''<html><head>
+<title>%s</title>
+<style type="text/css">%s</style>
+</head>
+<body bgcolor=#ffffff>
+%s
+<table width=100%% border=0 cellspacing=0 cellpadding=2>
+<tr class="location-bar"><td><big><strong>%s</strong></big></td>
+<td align=right valign=bottom>%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="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>
+%s
+%s</td>
+<td align=right>%s</td>
+</table>
+'''%(title, style, message, title, user_name, add_links, admin_links,
+    user_info))
+
+def parsePropsFromForm(db, cl, form, nodeid=0):
     '''Pull properties for the given class out of the form.
     '''
     props = {}
@@ -659,20 +796,27 @@ def parsePropsFromForm(cl, form, nodeid=0):
         proptype = cl.properties[key]
         if isinstance(proptype, hyperdb.String):
             value = form[key].value.strip()
+        elif isinstance(proptype, hyperdb.Password):
+            value = password.Password(form[key].value.strip())
         elif isinstance(proptype, hyperdb.Date):
             value = date.Date(form[key].value.strip())
         elif isinstance(proptype, hyperdb.Interval):
             value = date.Interval(form[key].value.strip())
         elif isinstance(proptype, hyperdb.Link):
             value = form[key].value.strip()
-            # handle key values
-            link = cl.properties[key].classname
-            if not num_re.match(value):
-                try:
-                    value = self.db.classes[link].lookup(value)
-                except:
-                    raise ValueError, 'property "%s": %s not a %s'%(
-                        key, value, link)
+            # see if it's the "no selection" choice
+            if value == '-1':
+                # don't set this property
+                continue
+            else:
+                # handle key values
+                link = cl.properties[key].classname
+                if not num_re.match(value):
+                    try:
+                        value = db.classes[link].lookup(value)
+                    except KeyError:
+                        raise ValueError, 'property "%s": %s not a %s'%(
+                            key, value, link)
         elif isinstance(proptype, hyperdb.Multilink):
             value = form[key]
             if type(value) != type([]):
@@ -684,11 +828,11 @@ def parsePropsFromForm(cl, form, nodeid=0):
             for entry in map(str, value):
                 if not num_re.match(entry):
                     try:
-                        entry = self.db.classes[link].lookup(entry)
-                    except:
+                        entry = db.classes[link].lookup(entry)
+                    except KeyError:
                         raise ValueError, \
-                            'property "%s": %s not a %s'%(key,
-                            entry, link)
+                            'property "%s": "%s" not an entry of %s'%(key,
+                            entry, link.capitalize())
                 l.append(entry)
             l.sort()
             value = l
@@ -701,6 +845,78 @@ def parsePropsFromForm(cl, form, nodeid=0):
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.39  2001/10/23 01:00:18  richard
+# Re-enabled login and registration access after lopping them off via
+# disabling access for anonymous users.
+# Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
+# a couple of bugs while I was there. Probably introduced a couple, but
+# things seem to work OK at the moment.
+#
+# Revision 1.38  2001/10/22 03:25:01  richard
+# Added configuration for:
+#  . anonymous user access and registration (deny/allow)
+#  . filter "widget" location on index page (top, bottom, both)
+# Updated some documentation.
+#
+# Revision 1.37  2001/10/21 07:26:35  richard
+# feature #473127: Filenames. I modified the file.index and htmltemplate
+#  source so that the filename is used in the link and the creation
+#  information is displayed.
+#
+# Revision 1.36  2001/10/21 04:44:50  richard
+# bug #473124: UI inconsistency with Link fields.
+#    This also prompted me to fix a fairly long-standing usability issue -
+#    that of being able to turn off certain filters.
+#
+# Revision 1.35  2001/10/21 00:17:54  richard
+# CGI interface view customisation section may now be hidden (patch from
+#  Roch'e Compaan.)
+#
+# Revision 1.34  2001/10/20 11:58:48  richard
+# Catch errors in login - no username or password supplied.
+# Fixed editing of password (Password property type) thanks Roch'e Compaan.
+#
+# Revision 1.33  2001/10/17 00:18:41  richard
+# Manually constructing cookie headers now.
+#
+# Revision 1.32  2001/10/16 03:36:21  richard
+# CGI interface wasn't handling checkboxes at all.
+#
+# Revision 1.31  2001/10/14 10:55:00  richard
+# Handle empty strings in HTML template Link function
+#
+# Revision 1.30  2001/10/09 07:38:58  richard
+# Pushed the base code for the extended schema CGI interface back into the
+# code cgi_client module so that future updates will be less painful.
+# Also removed a debugging print statement from cgi_client.
+#
+# Revision 1.29  2001/10/09 07:25:59  richard
+# Added the Password property type. See "pydoc roundup.password" for
+# implementation details. Have updated some of the documentation too.
+#
+# Revision 1.28  2001/10/08 00:34:31  richard
+# Change message was stuffing up for multilinks with no key property.
+#
+# Revision 1.27  2001/10/05 02:23:24  richard
+#  . roundup-admin create now prompts for property info if none is supplied
+#    on the command-line.
+#  . hyperdb Class getprops() method may now return only the mutable
+#    properties.
+#  . Login now uses cookies, which makes it a whole lot more flexible. We can
+#    now support anonymous user access (read-only, unless there's an
+#    "anonymous" user, in which case write access is permitted). Login
+#    handling has been moved into cgi_client.Client.main()
+#  . The "extended" schema is now the default in roundup init.
+#  . The schemas have had their page headings modified to cope with the new
+#    login handling. Existing installations should copy the interfaces.py
+#    file from the roundup lib directory to their instance home.
+#  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
+#    Ping - has been removed.
+#  . Fixed a whole bunch of places in the CGI interface where we should have
+#    been returning Not Found instead of throwing an exception.
+#  . Fixed a deviation from the spec: trying to modify the 'id' property of
+#    an item now throws an exception.
+#
 # Revision 1.26  2001/09/12 08:31:42  richard
 # handle cases where mime type is not guessable
 #