4ce3bba1a2c969749475d0efe670f11e6c2a4382
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 == '+'
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.
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.
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
1042 #
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...
1046 #
1047 # Revision 1.64 2001/11/26 22:56:35 richard
1048 # typo
1049 #
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.
1058 #
1059 # Fixed:
1060 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1061 #
1062 # Revision 1.62 2001/11/24 00:45:42 jhermann
1063 # typeof() instead of type(): avoid clash with database field(?) "type"
1064 #
1065 # Fixes this traceback:
1066 #
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
1073 #
1074 # Revision 1.61 2001/11/22 15:46:42 jhermann
1075 # Added module docstrings to all modules.
1076 #
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
1080 #
1081 # Revision 1.59 2001/11/21 03:21:13 richard
1082 # oops
1083 #
1084 # Revision 1.58 2001/11/21 03:11:28 richard
1085 # Better handling of new properties.
1086 #
1087 # Revision 1.57 2001/11/15 10:24:27 richard
1088 # handle the case where there is no file attached
1089 #
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
1092 #
1093 # Revision 1.55 2001/11/07 02:34:06 jhermann
1094 # Handling of damaged login cookies
1095 #
1096 # Revision 1.54 2001/11/07 01:16:12 richard
1097 # Remove the '=' padding from cookie value so quoting isn't an issue.
1098 #
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
1103 #
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
1108 #
1109 # Revision 1.51 2001/11/06 22:00:34 jhermann
1110 # Get debug level from ROUNDUP_DEBUG env var
1111 #
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).
1115 #
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.
1123 #
1124 # Revision 1.48 2001/11/03 01:30:18 richard
1125 # Oops. uses pagefoot now.
1126 #
1127 # Revision 1.47 2001/11/03 01:29:28 richard
1128 # Login page didn't have all close tags.
1129 #
1130 # Revision 1.46 2001/11/03 01:26:55 richard
1131 # possibly fix truncated base64'ed user:pass
1132 #
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
1138 #
1139 # Revision 1.44 2001/10/28 23:03:08 richard
1140 # Added more useful header to the classic schema.
1141 #
1142 # Revision 1.43 2001/10/24 00:01:42 richard
1143 # More fixes to lockout logic.
1144 #
1145 # Revision 1.42 2001/10/23 23:56:03 richard
1146 # HTML typo
1147 #
1148 # Revision 1.41 2001/10/23 23:52:35 richard
1149 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1150 #
1151 # Revision 1.40 2001/10/23 23:06:39 richard
1152 # Some cleanup.
1153 #
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.
1160 #
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.
1166 #
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.
1171 #
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.
1176 #
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.)
1180 #
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.
1184 #
1185 # Revision 1.33 2001/10/17 00:18:41 richard
1186 # Manually constructing cookie headers now.
1187 #
1188 # Revision 1.32 2001/10/16 03:36:21 richard
1189 # CGI interface wasn't handling checkboxes at all.
1190 #
1191 # Revision 1.31 2001/10/14 10:55:00 richard
1192 # Handle empty strings in HTML template Link function
1193 #
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.
1198 #
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.
1202 #
1203 # Revision 1.28 2001/10/08 00:34:31 richard
1204 # Change message was stuffing up for multilinks with no key property.
1205 #
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.
1225 #
1226 # Revision 1.26 2001/09/12 08:31:42 richard
1227 # handle cases where mime type is not guessable
1228 #
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.
1231 #
1232 # Revision 1.24 2001/08/29 04:49:39 richard
1233 # didn't clean up fully after debugging :(
1234 #
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).
1238 #
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.
1242 #
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.
1246 #
1247 # Revision 1.20 2001/08/12 06:32:36 richard
1248 # using isinstance(blah, Foo) now instead of isFooType
1249 #
1250 # Revision 1.19 2001/08/07 00:24:42 richard
1251 # stupid typo
1252 #
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.
1256 #
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).
1262 #
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.
1266 #
1267 # Revision 1.15 2001/08/02 00:34:10 richard
1268 # bleah syntax error
1269 #
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.
1272 #
1273 # Revision 1.13 2001/07/30 08:12:17 richard
1274 # Added time logging and file uploading to the templates.
1275 #
1276 # Revision 1.12 2001/07/30 06:26:31 richard
1277 # Added some documentation on how the newblah works.
1278 #
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.
1287 #
1288 # Revision 1.10 2001/07/30 02:37:34 richard
1289 # Temporary measure until we have decent schema migration...
1290 #
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.
1294 #
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)
1298 #
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 :)
1301 #
1302 # Revision 1.6 2001/07/29 04:04:00 richard
1303 # Moved some code around allowing for subclassing to change behaviour.
1304 #
1305 # Revision 1.5 2001/07/28 08:16:52 richard
1306 # New issue form handles lack of note better now.
1307 #
1308 # Revision 1.4 2001/07/28 00:34:34 richard
1309 # Fixed some non-string node ids.
1310 #
1311 # Revision 1.3 2001/07/23 03:56:30 richard
1312 # oops, missed a config removal
1313 #
1314 # Revision 1.2 2001/07/22 12:09:32 richard
1315 # Final commit of Grande Splite
1316 #
1317 # Revision 1.1 2001/07/22 11:58:35 richard
1318 # More Grande Splite
1319 #
1320 #
1321 # vim: set filetype=python ts=4 sw=4 et si