X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fcgi_client.py;h=c978b3d94fcd147abdbb582819b0b793b9fba0d7;hb=73bf8694bae43f859622944a706dc4dd441eae44;hp=1cb3113307c71a1dfc8ddd60af2eb571941bbe54;hpb=b0de4d96035cf130e7ca822c228b2375a7adfbe6;p=roundup.git diff --git a/roundup/cgi_client.py b/roundup/cgi_client.py index 1cb3113..c978b3d 100644 --- a/roundup/cgi_client.py +++ b/roundup/cgi_client.py @@ -15,21 +15,47 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: cgi_client.py,v 1.26 2001-09-12 08:31:42 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 +class NotFound(ValueError): + pass + class Client: - def __init__(self, out, db, env, user): + ''' + A note about login + ------------------ + + If the user has no login cookie, then they are anonymous. There + are two levels of anonymous use. If there is no 'anonymous' user, there + is no login at all and the database is opened in read-only mode. If the + '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 self.out = out - self.db = db self.env = env - self.user = user self.path = env['PATH_INFO'] self.split_path = self.path.split('/') @@ -60,7 +86,11 @@ class Client: else: message = '' style = open(os.path.join(self.TEMPLATES, 'style.css')).read() - userid = self.db.user.lookup(self.user) + if self.user is not None: + userid = self.db.user.lookup(self.user) + user_info = '(login: %s)'%(userid, self.user) + else: + user_info = '' self.write(''' %s @@ -68,10 +98,9 @@ class Client: %s - +
%s -(login: %s)
%s %s
-'''%(title, style, message, title, userid, self.user)) +'''%(title, style, message, title, user_info)) def pagefoot(self): if self.debug: @@ -111,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 @@ -122,6 +151,8 @@ class Client: filterspec = {} 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 @@ -137,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 '-' @@ -182,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): @@ -199,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 @@ -218,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 @@ -246,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): @@ -320,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) @@ -375,8 +435,12 @@ class Client: traceback.print_exc(None, s) message = '
%s
'%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 @@ -408,8 +472,9 @@ class Client: message = '
%s
'%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): @@ -433,34 +498,292 @@ class Client: else: raise Unauthorised - def main(self, dre=re.compile(r'([^\d]+)(\d+)'), nre=re.compile(r'new(\w+)')): + def login(self, message=None): + self.pagehead('Login to roundup', message) + self.write(''' + + + + + + + + + + +''') + if self.user is None and not self.ANONYMOUS_REGISTER == 'deny': + self.write(' + + + + + + + + + + + + + + + + + + + + +
Existing User Login
Login name:
Password:
New User Registration
marked items are optional...
Name:
Organisation:
E-Mail Address:
Phone:
Preferred Login name:
Password:
Password Again:
+''') + + 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 + 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) + except KeyError: + name = self.user + self.make_user_anonymous() + 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 + uid = self.db.user.lookup(self.user) + user = base64.encodestring('%s:%s'%(self.user, password))[:-1] + path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], + '')) + self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)}) + return self.index() + + def make_user_anonymous(self): + # make us anonymous if we can + try: + self.db.user.lookup('anonymous') + self.user = 'anonymous' + except KeyError: + self.user = None + + def logout(self, message=None): + self.make_user_anonymous() + # construct the logout cookie + path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], + '')) + 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(self.db, cl, self.form) + uid = cl.create(**props) + self.user = self.db.user.get(uid, 'username') + password = self.db.user.get(uid, 'password') + # construct the cookie + uid = self.db.user.lookup(self.user) + user = base64.encodestring('%s:%s'%(self.user, password))[:-1] + path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'], + '')) + self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)}) + return self.index() + + def main(self, dre=re.compile(r'([^\d]+)(\d+)'), + nre=re.compile(r'new(\w+)')): + + # determine the uid to use + self.db = self.instance.open('admin') + cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', '')) + user = 'anonymous' + if (cookie.has_key('roundup_user') and + cookie['roundup_user'].value != 'deleted'): + cookie = cookie['roundup_user'].value + user, password = base64.decodestring(cookie).split(':') + # make sure the user exists + try: + uid = self.db.user.lookup(user) + # now validate the password + if password != self.db.user.get(uid, 'password'): + user = 'anonymous' + except KeyError: + user = 'anonymous' + + # make sure the anonymous user is valid if we're using it + if user == 'anonymous': + 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) + + # 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 - m = dre.match(path[0]) - if m: - self.classname = m.group(1) - self.nodeid = m.group(2) - getattr(self, 'show%s'%self.classname)() - return - m = nre.match(path[0]) - if m: - self.classname = m.group(1) - getattr(self, 'new%s'%self.classname)() - return - self.classname = path[0] - self.list() - else: + 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: + cl = self.db.classes[self.classname] + except KeyError: + raise NotFound + 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 = '
%s
'%message + else: + message = '' + style = open(os.path.join(self.TEMPLATES, 'style.css')).read() + user_name = self.user or '' + if self.user == 'admin': + admin_links = ' | Class List' + else: + admin_links = '' + if self.user not in (None, 'anonymous'): + userid = self.db.user.lookup(self.user) + user_info = ''' +My Issues | +My Support | +My Details | Logout +'''%(userid, userid, userid) + else: + user_info = 'Login' + if self.user is not None: + add_links = ''' +| Add +Issue, +Support, +User +''' + else: + add_links = '' + self.write(''' +%s + + + +%s + + + + + + +
%s%s
All +Issues, +Support +| Unassigned +Issues, +Support +%s +%s%s
+'''%(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 = {} @@ -473,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([]): @@ -498,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 @@ -515,6 +845,81 @@ 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 +# # Revision 1.25 2001/08/29 05:30:49 richard # change messages weren't being saved when there was no-one on the nosy list. #