Code

4ce3bba1a2c969749475d0efe670f11e6c2a4382
[roundup.git] / roundup / cgi_client.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: cgi_client.py,v 1.66 2001-11-27 03:00:50 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
25 import binascii, Cookie, time
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31     pass
33 class NotFound(ValueError):
34     pass
36 class Client:
37     '''
38     A note about login
39     ------------------
41     If the user has no login cookie, then they are anonymous. There
42     are two levels of anonymous use. If there is no 'anonymous' user, there
43     is no login at all and the database is opened in read-only mode. If the
44     'anonymous' user exists, the user is logged in using that user (though
45     there is no cookie). This allows them to modify the database, and all
46     modifications are attributed to the 'anonymous' user.
49     Customisation
50     -------------
51       FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
52       ANONYMOUS_ACCESS - one of 'deny', 'allow'
53       ANONYMOUS_REGISTER - one of 'deny', 'allow'
55     from the roundup class:
56       INSTANCE_NAME - defaults to 'Roundup issue tracker'
58     '''
59     FILTER_POSITION = 'bottom'       # one of 'top', 'bottom', 'top and bottom'
60     ANONYMOUS_ACCESS = 'deny'        # one of 'deny', 'allow'
61     ANONYMOUS_REGISTER = 'deny'      # one of 'deny', 'allow'
63     def __init__(self, instance, request, env):
64         self.instance = instance
65         self.request = request
66         self.env = env
67         self.path = env['PATH_INFO']
68         self.split_path = self.path.split('/')
70         self.form = cgi.FieldStorage(environ=env)
71         self.headers_done = 0
72         try:
73             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
74         except ValueError:
75             # someone gave us a non-int debug level, turn it off
76             self.debug = 0
78     def getuid(self):
79         return self.db.user.lookup(self.user)
81     def header(self, headers={'Content-Type':'text/html'}):
82         '''Put up the appropriate header.
83         '''
84         if not headers.has_key('Content-Type'):
85             headers['Content-Type'] = 'text/html'
86         self.request.send_response(200)
87         for entry in headers.items():
88             self.request.send_header(*entry)
89         self.request.end_headers()
90         self.headers_done = 1
91         if self.debug:
92             self.headers_sent = headers
94     def pagehead(self, title, message=None):
95         url = self.env['SCRIPT_NAME'] + '/'
96         machine = self.env['SERVER_NAME']
97         port = self.env['SERVER_PORT']
98         if port != '80': machine = machine + ':' + port
99         base = urlparse.urlunparse(('http', machine, url, None, None, None))
100         if message is not None:
101             message = '<div class="system-msg">%s</div>'%message
102         else:
103             message = ''
104         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
105         user_name = self.user or ''
106         if self.user == 'admin':
107             admin_links = ' | <a href="list_classes">Class List</a>' \
108                           ' | <a href="user">User List</a>'
109         else:
110             admin_links = ''
111         if self.user not in (None, 'anonymous'):
112             userid = self.db.user.lookup(self.user)
113             user_info = '''
114 <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> |
115 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
116 '''%(userid, userid)
117         else:
118             user_info = _('<a href="login">Login</a>')
119         if self.user is not None:
120             add_links = '''
121 | Add
122 <a href="newissue">Issue</a>,
123 <a href="newuser">User</a>
124 '''
125         else:
126             add_links = ''
127         self.write('''<html><head>
128 <title>%s</title>
129 <style type="text/css">%s</style>
130 </head>
131 <body bgcolor=#ffffff>
132 %s
133 <table width=100%% border=0 cellspacing=0 cellpadding=2>
134 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
135 <td align=right valign=bottom>%s</td></tr>
136 <tr class="location-bar">
137 <td align=left>All
138 <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>
139 | Unassigned
140 <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>
141 %s
142 %s</td>
143 <td align=right>%s</td>
144 </table>
145 '''%(title, style, message, title, user_name, add_links, admin_links,
146     user_info))
148     def pagefoot(self):
149         if self.debug:
150             self.write('<hr><small><dl>')
151             self.write('<dt><b>Path</b></dt>')
152             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
153             keys = self.form.keys()
154             keys.sort()
155             if keys:
156                 self.write('<dt><b>Form entries</b></dt>')
157                 for k in self.form.keys():
158                     v = self.form.getvalue(k, "<empty>")
159                     if type(v) is type([]):
160                         # Multiple username fields specified
161                         v = "|".join(v)
162                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
163             keys = self.headers_sent.keys()
164             keys.sort()
165             self.write('<dt><b>Sent these HTTP headers</b></dt>')
166             for k in keys:
167                 v = self.headers_sent[k]
168                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
169             keys = self.env.keys()
170             keys.sort()
171             self.write('<dt><b>CGI environment</b></dt>')
172             for k in keys:
173                 v = self.env[k]
174                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
175             self.write('</dl></small>')
176         self.write('</body></html>')
178     def write(self, content):
179         if not self.headers_done:
180             self.header()
181         self.request.wfile.write(content)
183     def index_arg(self, arg):
184         ''' handle the args to index - they might be a list from the form
185             (ie. submitted from a form) or they might be a command-separated
186             single string (ie. manually constructed GET args)
187         '''
188         if self.form.has_key(arg):
189             arg =  self.form[arg]
190             if type(arg) == type([]):
191                 return [arg.value for arg in arg]
192             return arg.value.split(',')
193         return []
195     def index_filterspec(self, filter):
196         ''' pull the index filter spec from the form
198         Links and multilinks want to be lists - the rest are straight
199         strings.
200         '''
201         props = self.db.classes[self.classname].getprops()
202         # all the form args not starting with ':' are filters
203         filterspec = {}
204         for key in self.form.keys():
205             if key[0] == ':': continue
206             if not props.has_key(key): continue
207             if key not in filter: continue
208             prop = props[key]
209             value = self.form[key]
210             if (isinstance(prop, hyperdb.Link) or
211                     isinstance(prop, hyperdb.Multilink)):
212                 if type(value) == type([]):
213                     value = [arg.value for arg in value]
214                 else:
215                     value = value.value.split(',')
216                 l = filterspec.get(key, [])
217                 l = l + value
218                 filterspec[key] = l
219             else:
220                 filterspec[key] = value.value
221         return filterspec
223     def customization_widget(self):
224         ''' The customization widget is visible by default. The widget
225             visibility is remembered by show_customization.  Visibility
226             is not toggled if the action value is "Redisplay"
227         '''
228         if not self.form.has_key('show_customization'):
229             visible = 1
230         else:
231             visible = int(self.form['show_customization'].value)
232             if self.form.has_key('action'):
233                 if self.form['action'].value != 'Redisplay':
234                     visible = self.form['action'].value == '+'
235             
236         return visible
238     default_index_sort = ['-activity']
239     default_index_group = ['priority']
240     default_index_filter = ['status']
241     default_index_columns = ['id','activity','title','status','assignedto']
242     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
243     def index(self):
244         ''' put up an index
245         '''
246         self.classname = 'issue'
247         # see if the web has supplied us with any customisation info
248         defaults = 1
249         for key in ':sort', ':group', ':filter', ':columns':
250             if self.form.has_key(key):
251                 defaults = 0
252                 break
253         if defaults:
254             # no info supplied - use the defaults
255             sort = self.default_index_sort
256             group = self.default_index_group
257             filter = self.default_index_filter
258             columns = self.default_index_columns
259             filterspec = self.default_index_filterspec
260         else:
261             sort = self.index_arg(':sort')
262             group = self.index_arg(':group')
263             filter = self.index_arg(':filter')
264             columns = self.index_arg(':columns')
265             filterspec = self.index_filterspec(filter)
266         return self.list(columns=columns, filter=filter, group=group,
267             sort=sort, filterspec=filterspec)
269     # XXX deviates from spec - loses the '+' (that's a reserved character
270     # in URLS
271     def list(self, sort=None, group=None, filter=None, columns=None,
272             filterspec=None, show_customization=None):
273         ''' call the template index with the args
275             :sort    - sort by prop name, optionally preceeded with '-'
276                      to give descending or nothing for ascending sorting.
277             :group   - group by prop name, optionally preceeded with '-' or
278                      to sort in descending or nothing for ascending order.
279             :filter  - selects which props should be displayed in the filter
280                      section. Default is all.
281             :columns - selects the columns that should be displayed.
282                      Default is all.
284         '''
285         cn = self.classname
286         cl = self.db.classes[cn]
287         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
288             'classname': cn, 'instancename': self.INSTANCE_NAME})
289         if sort is None: sort = self.index_arg(':sort')
290         if group is None: group = self.index_arg(':group')
291         if filter is None: filter = self.index_arg(':filter')
292         if columns is None: columns = self.index_arg(':columns')
293         if filterspec is None: filterspec = self.index_filterspec(filter)
294         if show_customization is None:
295             show_customization = self.customization_widget()
297         index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
298         index.render(filterspec, filter, columns, sort, group,
299             show_customization=show_customization)
300         self.pagefoot()
302     def shownode(self, message=None):
303         ''' display an item
304         '''
305         cn = self.classname
306         cl = self.db.classes[cn]
308         # possibly perform an edit
309         keys = self.form.keys()
310         num_re = re.compile('^\d+$')
311         # don't try to set properties if the user has just logged in
312         if keys and not self.form.has_key('__login_name'):
313             try:
314                 props, changed = parsePropsFromForm(self.db, cl, self.form,
315                     self.nodeid)
317                 # set status to chatting if 'unread' or 'resolved'
318                 if 'status' not in changed:
319                     try:
320                         # determine the id of 'unread','resolved' and 'chatting'
321                         unread_id = self.db.status.lookup('unread')
322                         resolved_id = self.db.status.lookup('resolved')
323                         chatting_id = self.db.status.lookup('chatting')
324                     except KeyError:
325                         pass
326                     else:
327                         if (not props.has_key('status') or
328                                 props['status'] == unread_id or
329                                 props['status'] == resolved_id):
330                             props['status'] = chatting_id
331                             changed.append('status')
332                 note = None
333                 if self.form.has_key('__note'):
334                     note = self.form['__note']
335                     note = note.value
336                 if changed or note:
337                     cl.set(self.nodeid, **props)
338                     self._post_editnode(self.nodeid, changed)
339                     # and some nice feedback for the user
340                     message = '%s edited ok'%', '.join(changed)
341                 else:
342                     message = 'nothing changed'
343             except:
344                 s = StringIO.StringIO()
345                 traceback.print_exc(None, s)
346                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
348         # now the display
349         id = self.nodeid
350         if cl.getkey():
351             id = cl.get(id, cl.getkey())
352         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
354         nodeid = self.nodeid
356         # use the template to display the item
357         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
358         item.render(nodeid)
360         self.pagefoot()
361     showissue = shownode
362     showmsg = shownode
364     def showuser(self, message=None):
365         '''Display a user page for editing. Make sure the user is allowed
366             to edit this node, and also check for password changes.
367         '''
368         if self.user == 'anonymous':
369             raise Unauthorised
371         user = self.db.user
373         # get the username of the node being edited
374         node_user = user.get(self.nodeid, 'username')
376         if self.user not in ('admin', node_user):
377             raise Unauthorised
379         #
380         # perform any editing
381         #
382         keys = self.form.keys()
383         num_re = re.compile('^\d+$')
384         if keys:
385             try:
386                 props, changed = parsePropsFromForm(self.db, user, self.form,
387                     self.nodeid)
388                 set_cookie = 0
389                 if self.nodeid == self.getuid() and 'password' in changed:
390                     password = self.form['password'].value.strip()
391                     if password:
392                         set_cookie = password
393                     else:
394                         del props['password']
395                         del changed[changed.index('password')]
396                 user.set(self.nodeid, **props)
397                 self._post_editnode(self.nodeid, changed)
398                 # and some feedback for the user
399                 message = '%s edited ok'%', '.join(changed)
400             except:
401                 s = StringIO.StringIO()
402                 traceback.print_exc(None, s)
403                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
404         else:
405             set_cookie = 0
407         # fix the cookie if the password has changed
408         if set_cookie:
409             self.set_cookie(self.user, set_cookie)
411         #
412         # now the display
413         #
414         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
416         # use the template to display the item
417         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
418         item.render(self.nodeid)
419         self.pagefoot()
421     def showfile(self):
422         ''' display a file
423         '''
424         nodeid = self.nodeid
425         cl = self.db.file
426         mime_type = cl.get(nodeid, 'type')
427         if mime_type == 'message/rfc822':
428             mime_type = 'text/plain'
429         self.header(headers={'Content-Type': mime_type})
430         self.write(cl.get(nodeid, 'content'))
432     def _createnode(self):
433         ''' create a node based on the contents of the form
434         '''
435         cl = self.db.classes[self.classname]
436         props, dummy = parsePropsFromForm(self.db, cl, self.form)
437         return cl.create(**props)
439     def _post_editnode(self, nid, changes=None):
440         ''' do the linking and message sending part of the node creation
441         '''
442         cn = self.classname
443         cl = self.db.classes[cn]
444         # link if necessary
445         keys = self.form.keys()
446         for key in keys:
447             if key == ':multilink':
448                 value = self.form[key].value
449                 if type(value) != type([]): value = [value]
450                 for value in value:
451                     designator, property = value.split(':')
452                     link, nodeid = roundupdb.splitDesignator(designator)
453                     link = self.db.classes[link]
454                     value = link.get(nodeid, property)
455                     value.append(nid)
456                     link.set(nodeid, **{property: value})
457             elif key == ':link':
458                 value = self.form[key].value
459                 if type(value) != type([]): value = [value]
460                 for value in value:
461                     designator, property = value.split(':')
462                     link, nodeid = roundupdb.splitDesignator(designator)
463                     link = self.db.classes[link]
464                     link.set(nodeid, **{property: nid})
466         # handle file attachments
467         files = []
468         if self.form.has_key('__file'):
469             file = self.form['__file']
470             if file.filename:
471                 mime_type = mimetypes.guess_type(file.filename)[0]
472                 if not mime_type:
473                     mime_type = "application/octet-stream"
474                 # create the new file entry
475                 files.append(self.db.file.create(type=mime_type,
476                     name=file.filename, content=file.file.read()))
478         # generate an edit message
479         # don't bother if there's no messages or nosy list 
480         props = cl.getprops()
481         note = None
482         if self.form.has_key('__note'):
483             note = self.form['__note']
484             note = note.value
485         send = len(cl.get(nid, 'nosy', [])) or note
486         if (send and props.has_key('messages') and
487                 isinstance(props['messages'], hyperdb.Multilink) and
488                 props['messages'].classname == 'msg'):
490             # handle the note
491             edit_msg = 'This %s has been edited through the web.\n'%cn
492             if note:
493                 if '\n' in note:
494                     summary = re.split(r'\n\r?', note)[0]
495                 else:
496                     summary = note
497                 m = [edit_msg + '%s\n'%note]
498             else:
499                 summary = edit_msg
500                 m = [summary]
502             first = 1
503             for name, prop in props.items():
504                 if changes is not None and name not in changes: continue
505                 if first:
506                     m.append('\n-------')
507                     first = 0
508                 value = cl.get(nid, name, None)
509                 if isinstance(prop, hyperdb.Link):
510                     link = self.db.classes[prop.classname]
511                     key = link.labelprop(default_to_id=1)
512                     if value is not None and key:
513                         value = link.get(value, key)
514                     else:
515                         value = '-'
516                 elif isinstance(prop, hyperdb.Multilink):
517                     if value is None: value = []
518                     l = []
519                     link = self.db.classes[prop.classname]
520                     key = link.labelprop(default_to_id=1)
521                     for entry in value:
522                         if key:
523                             l.append(link.get(entry, key))
524                         else:
525                             l.append(entry)
526                     value = ', '.join(l)
527                 m.append('%s: %s'%(name, value))
529             # now create the message
530             content = '\n'.join(m)
531             message_id = self.db.msg.create(author=self.getuid(),
532                 recipients=[], date=date.Date('.'), summary=summary,
533                 content=content, files=files)
534             messages = cl.get(nid, 'messages')
535             messages.append(message_id)
536             props = {'messages': messages, 'files': files}
537             cl.set(nid, **props)
539     def newnode(self, message=None):
540         ''' Add a new node to the database.
541         
542         The form works in two modes: blank form and submission (that is,
543         the submission goes to the same URL). **Eventually this means that
544         the form will have previously entered information in it if
545         submission fails.
547         The new node will be created with the properties specified in the
548         form submission. For multilinks, multiple form entries are handled,
549         as are prop=value,value,value. You can't mix them though.
551         If the new node is to be referenced from somewhere else immediately
552         (ie. the new node is a file that is to be attached to a support
553         issue) then supply one of these arguments in addition to the usual
554         form entries:
555             :link=designator:property
556             :multilink=designator:property
557         ... which means that once the new node is created, the "property"
558         on the node given by "designator" should now reference the new
559         node's id. The node id will be appended to the multilink.
560         '''
561         cn = self.classname
562         cl = self.db.classes[cn]
564         # possibly perform a create
565         keys = self.form.keys()
566         if [i for i in keys if i[0] != ':']:
567             props = {}
568             try:
569                 nid = self._createnode()
570                 self._post_editnode(nid)
571                 # and some nice feedback for the user
572                 message = '%s created ok'%cn
573             except:
574                 s = StringIO.StringIO()
575                 traceback.print_exc(None, s)
576                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
577         self.pagehead('New %s'%self.classname.capitalize(), message)
579         # call the template
580         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
581             self.classname)
582         newitem.render(self.form)
584         self.pagefoot()
585     newissue = newnode
586     newuser = newnode
588     def newfile(self, message=None):
589         ''' Add a new file to the database.
590         
591         This form works very much the same way as newnode - it just has a
592         file upload.
593         '''
594         cn = self.classname
595         cl = self.db.classes[cn]
597         # possibly perform a create
598         keys = self.form.keys()
599         if [i for i in keys if i[0] != ':']:
600             try:
601                 file = self.form['content']
602                 mime_type = mimetypes.guess_type(file.filename)[0]
603                 if not mime_type:
604                     mime_type = "application/octet-stream"
605                 self._post_editnode(cl.create(content=file.file.read(),
606                     type=mime_type, name=file.filename))
607                 # and some nice feedback for the user
608                 message = '%s created ok'%cn
609             except:
610                 s = StringIO.StringIO()
611                 traceback.print_exc(None, s)
612                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
614         self.pagehead('New %s'%self.classname.capitalize(), message)
615         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
616             self.classname)
617         newitem.render(self.form)
618         self.pagefoot()
620     def classes(self, message=None):
621         ''' display a list of all the classes in the database
622         '''
623         if self.user == 'admin':
624             self.pagehead(_('Table of classes'), message)
625             classnames = self.db.classes.keys()
626             classnames.sort()
627             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
628             for cn in classnames:
629                 cl = self.db.getclass(cn)
630                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
631                 for key, value in cl.properties.items():
632                     if value is None: value = ''
633                     else: value = str(value)
634                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
635                         key, cgi.escape(value)))
636             self.write('</table>')
637             self.pagefoot()
638         else:
639             raise Unauthorised
641     def login(self, message=None, newuser_form=None, action='index'):
642         self.pagehead(_('Login to roundup'), message)
643         self.write('''
644 <table>
645 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
646 <form action="login_action" method=POST>
647 <input type="hidden" name="__destination_url" value="%s">
648 <tr><td align=right>Login name: </td>
649     <td><input name="__login_name"></td></tr>
650 <tr><td align=right>Password: </td>
651     <td><input type="password" name="__login_password"></td></tr>
652 <tr><td></td>
653     <td><input type="submit" value="Log In"></td></tr>
654 </form>
655 '''%action)
656         if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
657             self.write('</table>')
658             self.pagefoot()
659             return
660         values = {'realname': '', 'organisation': '', 'address': '',
661             'phone': '', 'username': '', 'password': '', 'confirm': ''}
662         if newuser_form is not None:
663             for key in newuser_form.keys():
664                 values[key] = newuser_form[key].value
665         self.write('''
666 <p>
667 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
668 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
669 <form action="newuser_action" method=POST>
670 <tr><td align=right><em>Name: </em></td>
671     <td><input name="realname" value="%(realname)s"></td></tr>
672 <tr><td align=right><em>Organisation: </em></td>
673     <td><input name="organisation" value="%(organisation)s"></td></tr>
674 <tr><td align=right>E-Mail Address: </td>
675     <td><input name="address" value="%(address)s"></td></tr>
676 <tr><td align=right><em>Phone: </em></td>
677     <td><input name="phone" value="%(phone)s"></td></tr>
678 <tr><td align=right>Preferred Login name: </td>
679     <td><input name="username" value="%(username)s"></td></tr>
680 <tr><td align=right>Password: </td>
681     <td><input type="password" name="password" value="%(password)s"></td></tr>
682 <tr><td align=right>Password Again: </td>
683     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
684 <tr><td></td>
685     <td><input type="submit" value="Register"></td></tr>
686 </form>
687 </table>
688 '''%values)
689         self.pagefoot()
691     def login_action(self, message=None):
692         if not self.form.has_key('__login_name'):
693             return self.login(message='Username required')
694         self.user = self.form['__login_name'].value
695         if self.form.has_key('__login_password'):
696             password = self.form['__login_password'].value
697         else:
698             password = ''
699         # make sure the user exists
700         try:
701             uid = self.db.user.lookup(self.user)
702         except KeyError:
703             name = self.user
704             self.make_user_anonymous()
705             return self.login(message=_('No such user "%(name)s"')%locals())
707         # and that the password is correct
708         pw = self.db.user.get(uid, 'password')
709         if password != self.db.user.get(uid, 'password'):
710             self.make_user_anonymous()
711             return self.login(message=_('Incorrect password'))
713         self.set_cookie(self.user, password)
715     def set_cookie(self, user, password):
716         # construct the cookie
717         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
718         if user[-1] == '=':
719           if user[-2] == '=':
720             user = user[:-2]
721           else:
722             user = user[:-1]
723         expire = Cookie._getdate(86400*365)
724         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
725         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
726             user, expire, path)})
728     def make_user_anonymous(self):
729         # make us anonymous if we can
730         try:
731             self.db.user.lookup('anonymous')
732             self.user = 'anonymous'
733         except KeyError:
734             self.user = None
736     def logout(self, message=None):
737         self.make_user_anonymous()
738         # construct the logout cookie
739         now = Cookie._getdate()
740         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
741         self.header({'Set-Cookie':
742             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
743             path)})
744         return self.login()
746     def newuser_action(self, message=None):
747         ''' create a new user based on the contents of the form and then
748         set the cookie
749         '''
750         # re-open the database as "admin"
751         self.db.close()
752         self.db = self.instance.open('admin')
754         # TODO: pre-check the required fields and username key property
755         cl = self.db.user
756         try:
757             props, dummy = parsePropsFromForm(self.db, cl, self.form)
758             uid = cl.create(**props)
759         except ValueError, message:
760             return self.login(message, newuser_form=self.form)
761         self.user = cl.get(uid, 'username')
762         password = cl.get(uid, 'password')
763         self.set_cookie(self.user, self.form['password'].value)
764         return self.index()
766     def main(self):
767         # determine the uid to use
768         self.db = self.instance.open('admin')
769         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
770         user = 'anonymous'
771         if (cookie.has_key('roundup_user') and
772                 cookie['roundup_user'].value != 'deleted'):
773             cookie = cookie['roundup_user'].value
774             if len(cookie)%4:
775               cookie = cookie + '='*(4-len(cookie)%4)
776             try:
777                 user, password = binascii.a2b_base64(cookie).split(':')
778             except (TypeError, binascii.Error, binascii.Incomplete):
779                 # damaged cookie!
780                 user, password = 'anonymous', ''
782             # make sure the user exists
783             try:
784                 uid = self.db.user.lookup(user)
785                 # now validate the password
786                 if password != self.db.user.get(uid, 'password'):
787                     user = 'anonymous'
788             except KeyError:
789                 user = 'anonymous'
791         # make sure the anonymous user is valid if we're using it
792         if user == 'anonymous':
793             self.make_user_anonymous()
794         else:
795             self.user = user
796         self.db.close()
798         # re-open the database for real, using the user
799         self.db = self.instance.open(self.user)
801         # now figure which function to call
802         path = self.split_path
804         # default action to index if the path has no information in it
805         if not path or path[0] in ('', 'index'):
806             action = 'index'
807         else:
808             action = path[0]
810         # Everthing ignores path[1:]
811         #  - The file download link generator actually relies on this - it
812         #    appends the name of the file to the URL so the download file name
813         #    is correct, but doesn't actually use it.
815         # everyone is allowed to try to log in
816         if action == 'login_action':
817             # do the login
818             self.login_action()
819             # figure the resulting page
820             action = self.form['__destination_url'].value
821             if not action:
822                 action = 'index'
823             return self.do_action(action)
825         # allow anonymous people to register
826         if action == 'newuser_action':
827             # if we don't have a login and anonymous people aren't allowed to
828             # register, then spit up the login form
829             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
830                 if action == 'login':
831                     return self.login()         # go to the index after login
832                 else:
833                     return self.login(action=action)
834             # add the user
835             self.newuser_action()
836             # figure the resulting page
837             action = self.form['__destination_url'].value
838             if not action:
839                 action = 'index'
840             return self.do_action(action)
842         # no login or registration, make sure totally anonymous access is OK
843         if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
844             if action == 'login':
845                 return self.login()             # go to the index after login
846             else:
847                 return self.login(action=action)
849         # just a regular action
850         return self.do_action(action)
852     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
853             nre=re.compile(r'new(\w+)')):
854         # here be the "normal" functionality
855         if action == 'index':
856             return self.index()
857         if action == 'list_classes':
858             return self.classes()
859         if action == 'login':
860             return self.login()
861         if action == 'logout':
862             return self.logout()
863         m = dre.match(action)
864         if m:
865             self.classname = m.group(1)
866             self.nodeid = m.group(2)
867             try:
868                 cl = self.db.classes[self.classname]
869             except KeyError:
870                 raise NotFound
871             try:
872                 cl.get(self.nodeid, 'id')
873             except IndexError:
874                 raise NotFound
875             try:
876                 func = getattr(self, 'show%s'%self.classname)
877             except AttributeError:
878                 raise NotFound
879             return func()
880         m = nre.match(action)
881         if m:
882             self.classname = m.group(1)
883             try:
884                 func = getattr(self, 'new%s'%self.classname)
885             except AttributeError:
886                 raise NotFound
887             return func()
888         self.classname = action
889         try:
890             self.db.getclass(self.classname)
891         except KeyError:
892             raise NotFound
893         return self.list()
895     def __del__(self):
896         self.db.close()
899 class ExtendedClient(Client): 
900     '''Includes pages and page heading information that relate to the
901        extended schema.
902     ''' 
903     showsupport = Client.shownode
904     showtimelog = Client.shownode
905     newsupport = Client.newnode
906     newtimelog = Client.newnode
908     default_index_sort = ['-activity']
909     default_index_group = ['priority']
910     default_index_filter = ['status']
911     default_index_columns = ['activity','status','title','assignedto']
912     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
914     def pagehead(self, title, message=None):
915         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
916         machine = self.env['SERVER_NAME']
917         port = self.env['SERVER_PORT']
918         if port != '80': machine = machine + ':' + port
919         base = urlparse.urlunparse(('http', machine, url, None, None, None))
920         if message is not None:
921             message = '<div class="system-msg">%s</div>'%message
922         else:
923             message = ''
924         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
925         user_name = self.user or ''
926         if self.user == 'admin':
927             admin_links = ' | <a href="list_classes">Class List</a>' \
928                           ' | <a href="user">User List</a>'
929         else:
930             admin_links = ''
931         if self.user not in (None, 'anonymous'):
932             userid = self.db.user.lookup(self.user)
933             user_info = '''
934 <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> |
935 <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> |
936 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
937 '''%(userid, userid, userid)
938         else:
939             user_info = '<a href="login">Login</a>'
940         if self.user is not None:
941             add_links = '''
942 | Add
943 <a href="newissue">Issue</a>,
944 <a href="newsupport">Support</a>,
945 <a href="newuser">User</a>
946 '''
947         else:
948             add_links = ''
949         self.write('''<html><head>
950 <title>%s</title>
951 <style type="text/css">%s</style>
952 </head>
953 <body bgcolor=#ffffff>
954 %s
955 <table width=100%% border=0 cellspacing=0 cellpadding=2>
956 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
957 <td align=right valign=bottom>%s</td></tr>
958 <tr class="location-bar">
959 <td align=left>All
960 <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>,
961 <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>
962 | Unassigned
963 <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>,
964 <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>
965 %s
966 %s</td>
967 <td align=right>%s</td>
968 </table>
969 '''%(title, style, message, title, user_name, add_links, admin_links,
970     user_info))
972 def parsePropsFromForm(db, cl, form, nodeid=0):
973     '''Pull properties for the given class out of the form.
974     '''
975     props = {}
976     changed = []
977     keys = form.keys()
978     num_re = re.compile('^\d+$')
979     for key in keys:
980         if not cl.properties.has_key(key):
981             continue
982         proptype = cl.properties[key]
983         if isinstance(proptype, hyperdb.String):
984             value = form[key].value.strip()
985         elif isinstance(proptype, hyperdb.Password):
986             value = password.Password(form[key].value.strip())
987         elif isinstance(proptype, hyperdb.Date):
988             value = date.Date(form[key].value.strip())
989         elif isinstance(proptype, hyperdb.Interval):
990             value = date.Interval(form[key].value.strip())
991         elif isinstance(proptype, hyperdb.Link):
992             value = form[key].value.strip()
993             # see if it's the "no selection" choice
994             if value == '-1':
995                 # don't set this property
996                 continue
997             else:
998                 # handle key values
999                 link = cl.properties[key].classname
1000                 if not num_re.match(value):
1001                     try:
1002                         value = db.classes[link].lookup(value)
1003                     except KeyError:
1004                         raise ValueError, 'property "%s": %s not a %s'%(
1005                             key, value, link)
1006         elif isinstance(proptype, hyperdb.Multilink):
1007             value = form[key]
1008             if type(value) != type([]):
1009                 value = [i.strip() for i in value.value.split(',')]
1010             else:
1011                 value = [i.value.strip() for i in value]
1012             link = cl.properties[key].classname
1013             l = []
1014             for entry in map(str, value):
1015                 if not num_re.match(entry):
1016                     try:
1017                         entry = db.classes[link].lookup(entry)
1018                     except KeyError:
1019                         raise ValueError, \
1020                             'property "%s": "%s" not an entry of %s'%(key,
1021                             entry, link.capitalize())
1022                 l.append(entry)
1023             l.sort()
1024             value = l
1025         props[key] = value
1027         # get the old value
1028         if nodeid:
1029             try:
1030                 existing = cl.get(nodeid, key)
1031             except KeyError:
1032                 # this might be a new property for which there is no existing
1033                 # value
1034                 if not cl.properties.has_key(key): raise
1036         # if changed, set it
1037         if nodeid and value != existing:
1038             changed.append(key)
1039             props[key] = value
1040     return props, changed
1043 # $Log: not supported by cvs2svn $
1044 # Revision 1.65  2001/11/26 23:00:53  richard
1045 # This config stuff is getting to be a real mess...
1047 # Revision 1.64  2001/11/26 22:56:35  richard
1048 # typo
1050 # Revision 1.63  2001/11/26 22:55:56  richard
1051 # Feature:
1052 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1053 #    the instance.
1054 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1055 #    signature info in e-mails.
1056 #  . Some more flexibility in the mail gateway and more error handling.
1057 #  . Login now takes you to the page you back to the were denied access to.
1059 # Fixed:
1060 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1062 # Revision 1.62  2001/11/24 00:45:42  jhermann
1063 # typeof() instead of type(): avoid clash with database field(?) "type"
1065 # Fixes this traceback:
1067 # Traceback (most recent call last):
1068 #   File "roundup\cgi_client.py", line 535, in newnode
1069 #     self._post_editnode(nid)
1070 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1071 #     if type(value) != type([]): value = [value]
1072 # UnboundLocalError: local variable 'type' referenced before assignment
1074 # Revision 1.61  2001/11/22 15:46:42  jhermann
1075 # Added module docstrings to all modules.
1077 # Revision 1.60  2001/11/21 22:57:28  jhermann
1078 # Added dummy hooks for I18N and some preliminary (test) markup of
1079 # translatable messages
1081 # Revision 1.59  2001/11/21 03:21:13  richard
1082 # oops
1084 # Revision 1.58  2001/11/21 03:11:28  richard
1085 # Better handling of new properties.
1087 # Revision 1.57  2001/11/15 10:24:27  richard
1088 # handle the case where there is no file attached
1090 # Revision 1.56  2001/11/14 21:35:21  richard
1091 #  . users may attach files to issues (and support in ext) through the web now
1093 # Revision 1.55  2001/11/07 02:34:06  jhermann
1094 # Handling of damaged login cookies
1096 # Revision 1.54  2001/11/07 01:16:12  richard
1097 # Remove the '=' padding from cookie value so quoting isn't an issue.
1099 # Revision 1.53  2001/11/06 23:22:05  jhermann
1100 # More IE fixes: it does not like quotes around cookie values; in the
1101 # hope this does not break anything for other browser; if it does, we
1102 # need to check HTTP_USER_AGENT
1104 # Revision 1.52  2001/11/06 23:11:22  jhermann
1105 # Fixed debug output in page footer; added expiry date to the login cookie
1106 # (expires 1 year in the future) to prevent probs with certain versions
1107 # of IE
1109 # Revision 1.51  2001/11/06 22:00:34  jhermann
1110 # Get debug level from ROUNDUP_DEBUG env var
1112 # Revision 1.50  2001/11/05 23:45:40  richard
1113 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1114 # Also made it present nicer error messages (not tracebacks).
1116 # Revision 1.49  2001/11/04 03:07:12  richard
1117 # Fixed various cookie-related bugs:
1118 #  . bug #477685 ] base64.decodestring breaks
1119 #  . bug #477837 ] lynx does not like the cookie
1120 #  . bug #477892 ] Password edit doesn't fix login cookie
1121 # Also closed a security hole - a logged-in user could edit another user's
1122 # details.
1124 # Revision 1.48  2001/11/03 01:30:18  richard
1125 # Oops. uses pagefoot now.
1127 # Revision 1.47  2001/11/03 01:29:28  richard
1128 # Login page didn't have all close tags.
1130 # Revision 1.46  2001/11/03 01:26:55  richard
1131 # possibly fix truncated base64'ed user:pass
1133 # Revision 1.45  2001/11/01 22:04:37  richard
1134 # Started work on supporting a pop3-fetching server
1135 # Fixed bugs:
1136 #  . bug #477104 ] HTML tag error in roundup-server
1137 #  . bug #477107 ] HTTP header problem
1139 # Revision 1.44  2001/10/28 23:03:08  richard
1140 # Added more useful header to the classic schema.
1142 # Revision 1.43  2001/10/24 00:01:42  richard
1143 # More fixes to lockout logic.
1145 # Revision 1.42  2001/10/23 23:56:03  richard
1146 # HTML typo
1148 # Revision 1.41  2001/10/23 23:52:35  richard
1149 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1151 # Revision 1.40  2001/10/23 23:06:39  richard
1152 # Some cleanup.
1154 # Revision 1.39  2001/10/23 01:00:18  richard
1155 # Re-enabled login and registration access after lopping them off via
1156 # disabling access for anonymous users.
1157 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1158 # a couple of bugs while I was there. Probably introduced a couple, but
1159 # things seem to work OK at the moment.
1161 # Revision 1.38  2001/10/22 03:25:01  richard
1162 # Added configuration for:
1163 #  . anonymous user access and registration (deny/allow)
1164 #  . filter "widget" location on index page (top, bottom, both)
1165 # Updated some documentation.
1167 # Revision 1.37  2001/10/21 07:26:35  richard
1168 # feature #473127: Filenames. I modified the file.index and htmltemplate
1169 #  source so that the filename is used in the link and the creation
1170 #  information is displayed.
1172 # Revision 1.36  2001/10/21 04:44:50  richard
1173 # bug #473124: UI inconsistency with Link fields.
1174 #    This also prompted me to fix a fairly long-standing usability issue -
1175 #    that of being able to turn off certain filters.
1177 # Revision 1.35  2001/10/21 00:17:54  richard
1178 # CGI interface view customisation section may now be hidden (patch from
1179 #  Roch'e Compaan.)
1181 # Revision 1.34  2001/10/20 11:58:48  richard
1182 # Catch errors in login - no username or password supplied.
1183 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1185 # Revision 1.33  2001/10/17 00:18:41  richard
1186 # Manually constructing cookie headers now.
1188 # Revision 1.32  2001/10/16 03:36:21  richard
1189 # CGI interface wasn't handling checkboxes at all.
1191 # Revision 1.31  2001/10/14 10:55:00  richard
1192 # Handle empty strings in HTML template Link function
1194 # Revision 1.30  2001/10/09 07:38:58  richard
1195 # Pushed the base code for the extended schema CGI interface back into the
1196 # code cgi_client module so that future updates will be less painful.
1197 # Also removed a debugging print statement from cgi_client.
1199 # Revision 1.29  2001/10/09 07:25:59  richard
1200 # Added the Password property type. See "pydoc roundup.password" for
1201 # implementation details. Have updated some of the documentation too.
1203 # Revision 1.28  2001/10/08 00:34:31  richard
1204 # Change message was stuffing up for multilinks with no key property.
1206 # Revision 1.27  2001/10/05 02:23:24  richard
1207 #  . roundup-admin create now prompts for property info if none is supplied
1208 #    on the command-line.
1209 #  . hyperdb Class getprops() method may now return only the mutable
1210 #    properties.
1211 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1212 #    now support anonymous user access (read-only, unless there's an
1213 #    "anonymous" user, in which case write access is permitted). Login
1214 #    handling has been moved into cgi_client.Client.main()
1215 #  . The "extended" schema is now the default in roundup init.
1216 #  . The schemas have had their page headings modified to cope with the new
1217 #    login handling. Existing installations should copy the interfaces.py
1218 #    file from the roundup lib directory to their instance home.
1219 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1220 #    Ping - has been removed.
1221 #  . Fixed a whole bunch of places in the CGI interface where we should have
1222 #    been returning Not Found instead of throwing an exception.
1223 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1224 #    an item now throws an exception.
1226 # Revision 1.26  2001/09/12 08:31:42  richard
1227 # handle cases where mime type is not guessable
1229 # Revision 1.25  2001/08/29 05:30:49  richard
1230 # change messages weren't being saved when there was no-one on the nosy list.
1232 # Revision 1.24  2001/08/29 04:49:39  richard
1233 # didn't clean up fully after debugging :(
1235 # Revision 1.23  2001/08/29 04:47:18  richard
1236 # Fixed CGI client change messages so they actually include the properties
1237 # changed (again).
1239 # Revision 1.22  2001/08/17 00:08:10  richard
1240 # reverted back to sending messages always regardless of who is doing the web
1241 # edit. change notes weren't being saved. bleah. hackish.
1243 # Revision 1.21  2001/08/15 23:43:18  richard
1244 # Fixed some isFooTypes that I missed.
1245 # Refactored some code in the CGI code.
1247 # Revision 1.20  2001/08/12 06:32:36  richard
1248 # using isinstance(blah, Foo) now instead of isFooType
1250 # Revision 1.19  2001/08/07 00:24:42  richard
1251 # stupid typo
1253 # Revision 1.18  2001/08/07 00:15:51  richard
1254 # Added the copyright/license notice to (nearly) all files at request of
1255 # Bizar Software.
1257 # Revision 1.17  2001/08/02 06:38:17  richard
1258 # Roundupdb now appends "mailing list" information to its messages which
1259 # include the e-mail address and web interface address. Templates may
1260 # override this in their db classes to include specific information (support
1261 # instructions, etc).
1263 # Revision 1.16  2001/08/02 05:55:25  richard
1264 # Web edit messages aren't sent to the person who did the edit any more. No
1265 # message is generated if they are the only person on the nosy list.
1267 # Revision 1.15  2001/08/02 00:34:10  richard
1268 # bleah syntax error
1270 # Revision 1.14  2001/08/02 00:26:16  richard
1271 # Changed the order of the information in the message generated by web edits.
1273 # Revision 1.13  2001/07/30 08:12:17  richard
1274 # Added time logging and file uploading to the templates.
1276 # Revision 1.12  2001/07/30 06:26:31  richard
1277 # Added some documentation on how the newblah works.
1279 # Revision 1.11  2001/07/30 06:17:45  richard
1280 # Features:
1281 #  . Added ability for cgi newblah forms to indicate that the new node
1282 #    should be linked somewhere.
1283 # Fixed:
1284 #  . Fixed the agument handling for the roundup-admin find command.
1285 #  . Fixed handling of summary when no note supplied for newblah. Again.
1286 #  . Fixed detection of no form in htmltemplate Field display.
1288 # Revision 1.10  2001/07/30 02:37:34  richard
1289 # Temporary measure until we have decent schema migration...
1291 # Revision 1.9  2001/07/30 01:25:07  richard
1292 # Default implementation is now "classic" rather than "extended" as one would
1293 # expect.
1295 # Revision 1.8  2001/07/29 08:27:40  richard
1296 # Fixed handling of passed-in values in form elements (ie. during a
1297 # drill-down)
1299 # Revision 1.7  2001/07/29 07:01:39  richard
1300 # Added vim command to all source so that we don't get no steenkin' tabs :)
1302 # Revision 1.6  2001/07/29 04:04:00  richard
1303 # Moved some code around allowing for subclassing to change behaviour.
1305 # Revision 1.5  2001/07/28 08:16:52  richard
1306 # New issue form handles lack of note better now.
1308 # Revision 1.4  2001/07/28 00:34:34  richard
1309 # Fixed some non-string node ids.
1311 # Revision 1.3  2001/07/23 03:56:30  richard
1312 # oops, missed a config removal
1314 # Revision 1.2  2001/07/22 12:09:32  richard
1315 # Final commit of Grande Splite
1317 # Revision 1.1  2001/07/22 11:58:35  richard
1318 # More Grande Splite
1321 # vim: set filetype=python ts=4 sw=4 et si