4b8c7c4361a650e084d3a236a0ae38d27f080e75
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.65 2001-11-26 23:00:53 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 m = [edit_msg]
501 first = 1
502 for name, prop in props.items():
503 if changes is not None and name not in changes: continue
504 if first:
505 m.append('\n-------')
506 first = 0
507 value = cl.get(nid, name, None)
508 if isinstance(prop, hyperdb.Link):
509 link = self.db.classes[prop.classname]
510 key = link.labelprop(default_to_id=1)
511 if value is not None and key:
512 value = link.get(value, key)
513 else:
514 value = '-'
515 elif isinstance(prop, hyperdb.Multilink):
516 if value is None: value = []
517 l = []
518 link = self.db.classes[prop.classname]
519 key = link.labelprop(default_to_id=1)
520 for entry in value:
521 if key:
522 l.append(link.get(entry, key))
523 else:
524 l.append(entry)
525 value = ', '.join(l)
526 m.append('%s: %s'%(name, value))
528 # now create the message
529 content = '\n'.join(m)
530 message_id = self.db.msg.create(author=self.getuid(),
531 recipients=[], date=date.Date('.'), summary=summary,
532 content=content, files=files)
533 messages = cl.get(nid, 'messages')
534 messages.append(message_id)
535 props = {'messages': messages, 'files': files}
536 cl.set(nid, **props)
538 def newnode(self, message=None):
539 ''' Add a new node to the database.
541 The form works in two modes: blank form and submission (that is,
542 the submission goes to the same URL). **Eventually this means that
543 the form will have previously entered information in it if
544 submission fails.
546 The new node will be created with the properties specified in the
547 form submission. For multilinks, multiple form entries are handled,
548 as are prop=value,value,value. You can't mix them though.
550 If the new node is to be referenced from somewhere else immediately
551 (ie. the new node is a file that is to be attached to a support
552 issue) then supply one of these arguments in addition to the usual
553 form entries:
554 :link=designator:property
555 :multilink=designator:property
556 ... which means that once the new node is created, the "property"
557 on the node given by "designator" should now reference the new
558 node's id. The node id will be appended to the multilink.
559 '''
560 cn = self.classname
561 cl = self.db.classes[cn]
563 # possibly perform a create
564 keys = self.form.keys()
565 if [i for i in keys if i[0] != ':']:
566 props = {}
567 try:
568 nid = self._createnode()
569 self._post_editnode(nid)
570 # and some nice feedback for the user
571 message = '%s created ok'%cn
572 except:
573 s = StringIO.StringIO()
574 traceback.print_exc(None, s)
575 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
576 self.pagehead('New %s'%self.classname.capitalize(), message)
578 # call the template
579 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
580 self.classname)
581 newitem.render(self.form)
583 self.pagefoot()
584 newissue = newnode
585 newuser = newnode
587 def newfile(self, message=None):
588 ''' Add a new file to the database.
590 This form works very much the same way as newnode - it just has a
591 file upload.
592 '''
593 cn = self.classname
594 cl = self.db.classes[cn]
596 # possibly perform a create
597 keys = self.form.keys()
598 if [i for i in keys if i[0] != ':']:
599 try:
600 file = self.form['content']
601 mime_type = mimetypes.guess_type(file.filename)[0]
602 if not mime_type:
603 mime_type = "application/octet-stream"
604 self._post_editnode(cl.create(content=file.file.read(),
605 type=mime_type, name=file.filename))
606 # and some nice feedback for the user
607 message = '%s created ok'%cn
608 except:
609 s = StringIO.StringIO()
610 traceback.print_exc(None, s)
611 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
613 self.pagehead('New %s'%self.classname.capitalize(), message)
614 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
615 self.classname)
616 newitem.render(self.form)
617 self.pagefoot()
619 def classes(self, message=None):
620 ''' display a list of all the classes in the database
621 '''
622 if self.user == 'admin':
623 self.pagehead(_('Table of classes'), message)
624 classnames = self.db.classes.keys()
625 classnames.sort()
626 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
627 for cn in classnames:
628 cl = self.db.getclass(cn)
629 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
630 for key, value in cl.properties.items():
631 if value is None: value = ''
632 else: value = str(value)
633 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
634 key, cgi.escape(value)))
635 self.write('</table>')
636 self.pagefoot()
637 else:
638 raise Unauthorised
640 def login(self, message=None, newuser_form=None, action='index'):
641 self.pagehead(_('Login to roundup'), message)
642 self.write('''
643 <table>
644 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
645 <form action="login_action" method=POST>
646 <input type="hidden" name="__destination_url" value="%s">
647 <tr><td align=right>Login name: </td>
648 <td><input name="__login_name"></td></tr>
649 <tr><td align=right>Password: </td>
650 <td><input type="password" name="__login_password"></td></tr>
651 <tr><td></td>
652 <td><input type="submit" value="Log In"></td></tr>
653 </form>
654 '''%action)
655 if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
656 self.write('</table>')
657 self.pagefoot()
658 return
659 values = {'realname': '', 'organisation': '', 'address': '',
660 'phone': '', 'username': '', 'password': '', 'confirm': ''}
661 if newuser_form is not None:
662 for key in newuser_form.keys():
663 values[key] = newuser_form[key].value
664 self.write('''
665 <p>
666 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
667 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
668 <form action="newuser_action" method=POST>
669 <tr><td align=right><em>Name: </em></td>
670 <td><input name="realname" value="%(realname)s"></td></tr>
671 <tr><td align=right><em>Organisation: </em></td>
672 <td><input name="organisation" value="%(organisation)s"></td></tr>
673 <tr><td align=right>E-Mail Address: </td>
674 <td><input name="address" value="%(address)s"></td></tr>
675 <tr><td align=right><em>Phone: </em></td>
676 <td><input name="phone" value="%(phone)s"></td></tr>
677 <tr><td align=right>Preferred Login name: </td>
678 <td><input name="username" value="%(username)s"></td></tr>
679 <tr><td align=right>Password: </td>
680 <td><input type="password" name="password" value="%(password)s"></td></tr>
681 <tr><td align=right>Password Again: </td>
682 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
683 <tr><td></td>
684 <td><input type="submit" value="Register"></td></tr>
685 </form>
686 </table>
687 '''%values)
688 self.pagefoot()
690 def login_action(self, message=None):
691 if not self.form.has_key('__login_name'):
692 return self.login(message='Username required')
693 self.user = self.form['__login_name'].value
694 if self.form.has_key('__login_password'):
695 password = self.form['__login_password'].value
696 else:
697 password = ''
698 # make sure the user exists
699 try:
700 uid = self.db.user.lookup(self.user)
701 except KeyError:
702 name = self.user
703 self.make_user_anonymous()
704 return self.login(message=_('No such user "%(name)s"')%locals())
706 # and that the password is correct
707 pw = self.db.user.get(uid, 'password')
708 if password != self.db.user.get(uid, 'password'):
709 self.make_user_anonymous()
710 return self.login(message=_('Incorrect password'))
712 self.set_cookie(self.user, password)
714 def set_cookie(self, user, password):
715 # construct the cookie
716 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
717 if user[-1] == '=':
718 if user[-2] == '=':
719 user = user[:-2]
720 else:
721 user = user[:-1]
722 expire = Cookie._getdate(86400*365)
723 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
724 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
725 user, expire, path)})
727 def make_user_anonymous(self):
728 # make us anonymous if we can
729 try:
730 self.db.user.lookup('anonymous')
731 self.user = 'anonymous'
732 except KeyError:
733 self.user = None
735 def logout(self, message=None):
736 self.make_user_anonymous()
737 # construct the logout cookie
738 now = Cookie._getdate()
739 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
740 self.header({'Set-Cookie':
741 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
742 path)})
743 return self.login()
745 def newuser_action(self, message=None):
746 ''' create a new user based on the contents of the form and then
747 set the cookie
748 '''
749 # re-open the database as "admin"
750 self.db.close()
751 self.db = self.instance.open('admin')
753 # TODO: pre-check the required fields and username key property
754 cl = self.db.user
755 try:
756 props, dummy = parsePropsFromForm(self.db, cl, self.form)
757 uid = cl.create(**props)
758 except ValueError, message:
759 return self.login(message, newuser_form=self.form)
760 self.user = cl.get(uid, 'username')
761 password = cl.get(uid, 'password')
762 self.set_cookie(self.user, self.form['password'].value)
763 return self.index()
765 def main(self):
766 # determine the uid to use
767 self.db = self.instance.open('admin')
768 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
769 user = 'anonymous'
770 if (cookie.has_key('roundup_user') and
771 cookie['roundup_user'].value != 'deleted'):
772 cookie = cookie['roundup_user'].value
773 if len(cookie)%4:
774 cookie = cookie + '='*(4-len(cookie)%4)
775 try:
776 user, password = binascii.a2b_base64(cookie).split(':')
777 except (TypeError, binascii.Error, binascii.Incomplete):
778 # damaged cookie!
779 user, password = 'anonymous', ''
781 # make sure the user exists
782 try:
783 uid = self.db.user.lookup(user)
784 # now validate the password
785 if password != self.db.user.get(uid, 'password'):
786 user = 'anonymous'
787 except KeyError:
788 user = 'anonymous'
790 # make sure the anonymous user is valid if we're using it
791 if user == 'anonymous':
792 self.make_user_anonymous()
793 else:
794 self.user = user
795 self.db.close()
797 # re-open the database for real, using the user
798 self.db = self.instance.open(self.user)
800 # now figure which function to call
801 path = self.split_path
803 # default action to index if the path has no information in it
804 if not path or path[0] in ('', 'index'):
805 action = 'index'
806 else:
807 action = path[0]
809 # Everthing ignores path[1:]
810 # - The file download link generator actually relies on this - it
811 # appends the name of the file to the URL so the download file name
812 # is correct, but doesn't actually use it.
814 # everyone is allowed to try to log in
815 if action == 'login_action':
816 # do the login
817 self.login_action()
818 # figure the resulting page
819 action = self.form['__destination_url'].value
820 if not action:
821 action = 'index'
822 return self.do_action(action)
824 # allow anonymous people to register
825 if action == 'newuser_action':
826 # if we don't have a login and anonymous people aren't allowed to
827 # register, then spit up the login form
828 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
829 if action == 'login':
830 return self.login() # go to the index after login
831 else:
832 return self.login(action=action)
833 # add the user
834 self.newuser_action()
835 # figure the resulting page
836 action = self.form['__destination_url'].value
837 if not action:
838 action = 'index'
839 return self.do_action(action)
841 # no login or registration, make sure totally anonymous access is OK
842 if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
843 if action == 'login':
844 return self.login() # go to the index after login
845 else:
846 return self.login(action=action)
848 # just a regular action
849 return self.do_action(action)
851 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
852 nre=re.compile(r'new(\w+)')):
853 # here be the "normal" functionality
854 if action == 'index':
855 return self.index()
856 if action == 'list_classes':
857 return self.classes()
858 if action == 'login':
859 return self.login()
860 if action == 'logout':
861 return self.logout()
862 m = dre.match(action)
863 if m:
864 self.classname = m.group(1)
865 self.nodeid = m.group(2)
866 try:
867 cl = self.db.classes[self.classname]
868 except KeyError:
869 raise NotFound
870 try:
871 cl.get(self.nodeid, 'id')
872 except IndexError:
873 raise NotFound
874 try:
875 func = getattr(self, 'show%s'%self.classname)
876 except AttributeError:
877 raise NotFound
878 return func()
879 m = nre.match(action)
880 if m:
881 self.classname = m.group(1)
882 try:
883 func = getattr(self, 'new%s'%self.classname)
884 except AttributeError:
885 raise NotFound
886 return func()
887 self.classname = action
888 try:
889 self.db.getclass(self.classname)
890 except KeyError:
891 raise NotFound
892 return self.list()
894 def __del__(self):
895 self.db.close()
898 class ExtendedClient(Client):
899 '''Includes pages and page heading information that relate to the
900 extended schema.
901 '''
902 showsupport = Client.shownode
903 showtimelog = Client.shownode
904 newsupport = Client.newnode
905 newtimelog = Client.newnode
907 default_index_sort = ['-activity']
908 default_index_group = ['priority']
909 default_index_filter = ['status']
910 default_index_columns = ['activity','status','title','assignedto']
911 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
913 def pagehead(self, title, message=None):
914 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
915 machine = self.env['SERVER_NAME']
916 port = self.env['SERVER_PORT']
917 if port != '80': machine = machine + ':' + port
918 base = urlparse.urlunparse(('http', machine, url, None, None, None))
919 if message is not None:
920 message = '<div class="system-msg">%s</div>'%message
921 else:
922 message = ''
923 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
924 user_name = self.user or ''
925 if self.user == 'admin':
926 admin_links = ' | <a href="list_classes">Class List</a>' \
927 ' | <a href="user">User List</a>'
928 else:
929 admin_links = ''
930 if self.user not in (None, 'anonymous'):
931 userid = self.db.user.lookup(self.user)
932 user_info = '''
933 <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> |
934 <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> |
935 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
936 '''%(userid, userid, userid)
937 else:
938 user_info = '<a href="login">Login</a>'
939 if self.user is not None:
940 add_links = '''
941 | Add
942 <a href="newissue">Issue</a>,
943 <a href="newsupport">Support</a>,
944 <a href="newuser">User</a>
945 '''
946 else:
947 add_links = ''
948 self.write('''<html><head>
949 <title>%s</title>
950 <style type="text/css">%s</style>
951 </head>
952 <body bgcolor=#ffffff>
953 %s
954 <table width=100%% border=0 cellspacing=0 cellpadding=2>
955 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
956 <td align=right valign=bottom>%s</td></tr>
957 <tr class="location-bar">
958 <td align=left>All
959 <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>,
960 <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>
961 | Unassigned
962 <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>,
963 <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>
964 %s
965 %s</td>
966 <td align=right>%s</td>
967 </table>
968 '''%(title, style, message, title, user_name, add_links, admin_links,
969 user_info))
971 def parsePropsFromForm(db, cl, form, nodeid=0):
972 '''Pull properties for the given class out of the form.
973 '''
974 props = {}
975 changed = []
976 keys = form.keys()
977 num_re = re.compile('^\d+$')
978 for key in keys:
979 if not cl.properties.has_key(key):
980 continue
981 proptype = cl.properties[key]
982 if isinstance(proptype, hyperdb.String):
983 value = form[key].value.strip()
984 elif isinstance(proptype, hyperdb.Password):
985 value = password.Password(form[key].value.strip())
986 elif isinstance(proptype, hyperdb.Date):
987 value = date.Date(form[key].value.strip())
988 elif isinstance(proptype, hyperdb.Interval):
989 value = date.Interval(form[key].value.strip())
990 elif isinstance(proptype, hyperdb.Link):
991 value = form[key].value.strip()
992 # see if it's the "no selection" choice
993 if value == '-1':
994 # don't set this property
995 continue
996 else:
997 # handle key values
998 link = cl.properties[key].classname
999 if not num_re.match(value):
1000 try:
1001 value = db.classes[link].lookup(value)
1002 except KeyError:
1003 raise ValueError, 'property "%s": %s not a %s'%(
1004 key, value, link)
1005 elif isinstance(proptype, hyperdb.Multilink):
1006 value = form[key]
1007 if type(value) != type([]):
1008 value = [i.strip() for i in value.value.split(',')]
1009 else:
1010 value = [i.value.strip() for i in value]
1011 link = cl.properties[key].classname
1012 l = []
1013 for entry in map(str, value):
1014 if not num_re.match(entry):
1015 try:
1016 entry = db.classes[link].lookup(entry)
1017 except KeyError:
1018 raise ValueError, \
1019 'property "%s": "%s" not an entry of %s'%(key,
1020 entry, link.capitalize())
1021 l.append(entry)
1022 l.sort()
1023 value = l
1024 props[key] = value
1026 # get the old value
1027 if nodeid:
1028 try:
1029 existing = cl.get(nodeid, key)
1030 except KeyError:
1031 # this might be a new property for which there is no existing
1032 # value
1033 if not cl.properties.has_key(key): raise
1035 # if changed, set it
1036 if nodeid and value != existing:
1037 changed.append(key)
1038 props[key] = value
1039 return props, changed
1041 #
1042 # $Log: not supported by cvs2svn $
1043 # Revision 1.64 2001/11/26 22:56:35 richard
1044 # typo
1045 #
1046 # Revision 1.63 2001/11/26 22:55:56 richard
1047 # Feature:
1048 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1049 # the instance.
1050 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1051 # signature info in e-mails.
1052 # . Some more flexibility in the mail gateway and more error handling.
1053 # . Login now takes you to the page you back to the were denied access to.
1054 #
1055 # Fixed:
1056 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1057 #
1058 # Revision 1.62 2001/11/24 00:45:42 jhermann
1059 # typeof() instead of type(): avoid clash with database field(?) "type"
1060 #
1061 # Fixes this traceback:
1062 #
1063 # Traceback (most recent call last):
1064 # File "roundup\cgi_client.py", line 535, in newnode
1065 # self._post_editnode(nid)
1066 # File "roundup\cgi_client.py", line 415, in _post_editnode
1067 # if type(value) != type([]): value = [value]
1068 # UnboundLocalError: local variable 'type' referenced before assignment
1069 #
1070 # Revision 1.61 2001/11/22 15:46:42 jhermann
1071 # Added module docstrings to all modules.
1072 #
1073 # Revision 1.60 2001/11/21 22:57:28 jhermann
1074 # Added dummy hooks for I18N and some preliminary (test) markup of
1075 # translatable messages
1076 #
1077 # Revision 1.59 2001/11/21 03:21:13 richard
1078 # oops
1079 #
1080 # Revision 1.58 2001/11/21 03:11:28 richard
1081 # Better handling of new properties.
1082 #
1083 # Revision 1.57 2001/11/15 10:24:27 richard
1084 # handle the case where there is no file attached
1085 #
1086 # Revision 1.56 2001/11/14 21:35:21 richard
1087 # . users may attach files to issues (and support in ext) through the web now
1088 #
1089 # Revision 1.55 2001/11/07 02:34:06 jhermann
1090 # Handling of damaged login cookies
1091 #
1092 # Revision 1.54 2001/11/07 01:16:12 richard
1093 # Remove the '=' padding from cookie value so quoting isn't an issue.
1094 #
1095 # Revision 1.53 2001/11/06 23:22:05 jhermann
1096 # More IE fixes: it does not like quotes around cookie values; in the
1097 # hope this does not break anything for other browser; if it does, we
1098 # need to check HTTP_USER_AGENT
1099 #
1100 # Revision 1.52 2001/11/06 23:11:22 jhermann
1101 # Fixed debug output in page footer; added expiry date to the login cookie
1102 # (expires 1 year in the future) to prevent probs with certain versions
1103 # of IE
1104 #
1105 # Revision 1.51 2001/11/06 22:00:34 jhermann
1106 # Get debug level from ROUNDUP_DEBUG env var
1107 #
1108 # Revision 1.50 2001/11/05 23:45:40 richard
1109 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1110 # Also made it present nicer error messages (not tracebacks).
1111 #
1112 # Revision 1.49 2001/11/04 03:07:12 richard
1113 # Fixed various cookie-related bugs:
1114 # . bug #477685 ] base64.decodestring breaks
1115 # . bug #477837 ] lynx does not like the cookie
1116 # . bug #477892 ] Password edit doesn't fix login cookie
1117 # Also closed a security hole - a logged-in user could edit another user's
1118 # details.
1119 #
1120 # Revision 1.48 2001/11/03 01:30:18 richard
1121 # Oops. uses pagefoot now.
1122 #
1123 # Revision 1.47 2001/11/03 01:29:28 richard
1124 # Login page didn't have all close tags.
1125 #
1126 # Revision 1.46 2001/11/03 01:26:55 richard
1127 # possibly fix truncated base64'ed user:pass
1128 #
1129 # Revision 1.45 2001/11/01 22:04:37 richard
1130 # Started work on supporting a pop3-fetching server
1131 # Fixed bugs:
1132 # . bug #477104 ] HTML tag error in roundup-server
1133 # . bug #477107 ] HTTP header problem
1134 #
1135 # Revision 1.44 2001/10/28 23:03:08 richard
1136 # Added more useful header to the classic schema.
1137 #
1138 # Revision 1.43 2001/10/24 00:01:42 richard
1139 # More fixes to lockout logic.
1140 #
1141 # Revision 1.42 2001/10/23 23:56:03 richard
1142 # HTML typo
1143 #
1144 # Revision 1.41 2001/10/23 23:52:35 richard
1145 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1146 #
1147 # Revision 1.40 2001/10/23 23:06:39 richard
1148 # Some cleanup.
1149 #
1150 # Revision 1.39 2001/10/23 01:00:18 richard
1151 # Re-enabled login and registration access after lopping them off via
1152 # disabling access for anonymous users.
1153 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1154 # a couple of bugs while I was there. Probably introduced a couple, but
1155 # things seem to work OK at the moment.
1156 #
1157 # Revision 1.38 2001/10/22 03:25:01 richard
1158 # Added configuration for:
1159 # . anonymous user access and registration (deny/allow)
1160 # . filter "widget" location on index page (top, bottom, both)
1161 # Updated some documentation.
1162 #
1163 # Revision 1.37 2001/10/21 07:26:35 richard
1164 # feature #473127: Filenames. I modified the file.index and htmltemplate
1165 # source so that the filename is used in the link and the creation
1166 # information is displayed.
1167 #
1168 # Revision 1.36 2001/10/21 04:44:50 richard
1169 # bug #473124: UI inconsistency with Link fields.
1170 # This also prompted me to fix a fairly long-standing usability issue -
1171 # that of being able to turn off certain filters.
1172 #
1173 # Revision 1.35 2001/10/21 00:17:54 richard
1174 # CGI interface view customisation section may now be hidden (patch from
1175 # Roch'e Compaan.)
1176 #
1177 # Revision 1.34 2001/10/20 11:58:48 richard
1178 # Catch errors in login - no username or password supplied.
1179 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1180 #
1181 # Revision 1.33 2001/10/17 00:18:41 richard
1182 # Manually constructing cookie headers now.
1183 #
1184 # Revision 1.32 2001/10/16 03:36:21 richard
1185 # CGI interface wasn't handling checkboxes at all.
1186 #
1187 # Revision 1.31 2001/10/14 10:55:00 richard
1188 # Handle empty strings in HTML template Link function
1189 #
1190 # Revision 1.30 2001/10/09 07:38:58 richard
1191 # Pushed the base code for the extended schema CGI interface back into the
1192 # code cgi_client module so that future updates will be less painful.
1193 # Also removed a debugging print statement from cgi_client.
1194 #
1195 # Revision 1.29 2001/10/09 07:25:59 richard
1196 # Added the Password property type. See "pydoc roundup.password" for
1197 # implementation details. Have updated some of the documentation too.
1198 #
1199 # Revision 1.28 2001/10/08 00:34:31 richard
1200 # Change message was stuffing up for multilinks with no key property.
1201 #
1202 # Revision 1.27 2001/10/05 02:23:24 richard
1203 # . roundup-admin create now prompts for property info if none is supplied
1204 # on the command-line.
1205 # . hyperdb Class getprops() method may now return only the mutable
1206 # properties.
1207 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1208 # now support anonymous user access (read-only, unless there's an
1209 # "anonymous" user, in which case write access is permitted). Login
1210 # handling has been moved into cgi_client.Client.main()
1211 # . The "extended" schema is now the default in roundup init.
1212 # . The schemas have had their page headings modified to cope with the new
1213 # login handling. Existing installations should copy the interfaces.py
1214 # file from the roundup lib directory to their instance home.
1215 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1216 # Ping - has been removed.
1217 # . Fixed a whole bunch of places in the CGI interface where we should have
1218 # been returning Not Found instead of throwing an exception.
1219 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1220 # an item now throws an exception.
1221 #
1222 # Revision 1.26 2001/09/12 08:31:42 richard
1223 # handle cases where mime type is not guessable
1224 #
1225 # Revision 1.25 2001/08/29 05:30:49 richard
1226 # change messages weren't being saved when there was no-one on the nosy list.
1227 #
1228 # Revision 1.24 2001/08/29 04:49:39 richard
1229 # didn't clean up fully after debugging :(
1230 #
1231 # Revision 1.23 2001/08/29 04:47:18 richard
1232 # Fixed CGI client change messages so they actually include the properties
1233 # changed (again).
1234 #
1235 # Revision 1.22 2001/08/17 00:08:10 richard
1236 # reverted back to sending messages always regardless of who is doing the web
1237 # edit. change notes weren't being saved. bleah. hackish.
1238 #
1239 # Revision 1.21 2001/08/15 23:43:18 richard
1240 # Fixed some isFooTypes that I missed.
1241 # Refactored some code in the CGI code.
1242 #
1243 # Revision 1.20 2001/08/12 06:32:36 richard
1244 # using isinstance(blah, Foo) now instead of isFooType
1245 #
1246 # Revision 1.19 2001/08/07 00:24:42 richard
1247 # stupid typo
1248 #
1249 # Revision 1.18 2001/08/07 00:15:51 richard
1250 # Added the copyright/license notice to (nearly) all files at request of
1251 # Bizar Software.
1252 #
1253 # Revision 1.17 2001/08/02 06:38:17 richard
1254 # Roundupdb now appends "mailing list" information to its messages which
1255 # include the e-mail address and web interface address. Templates may
1256 # override this in their db classes to include specific information (support
1257 # instructions, etc).
1258 #
1259 # Revision 1.16 2001/08/02 05:55:25 richard
1260 # Web edit messages aren't sent to the person who did the edit any more. No
1261 # message is generated if they are the only person on the nosy list.
1262 #
1263 # Revision 1.15 2001/08/02 00:34:10 richard
1264 # bleah syntax error
1265 #
1266 # Revision 1.14 2001/08/02 00:26:16 richard
1267 # Changed the order of the information in the message generated by web edits.
1268 #
1269 # Revision 1.13 2001/07/30 08:12:17 richard
1270 # Added time logging and file uploading to the templates.
1271 #
1272 # Revision 1.12 2001/07/30 06:26:31 richard
1273 # Added some documentation on how the newblah works.
1274 #
1275 # Revision 1.11 2001/07/30 06:17:45 richard
1276 # Features:
1277 # . Added ability for cgi newblah forms to indicate that the new node
1278 # should be linked somewhere.
1279 # Fixed:
1280 # . Fixed the agument handling for the roundup-admin find command.
1281 # . Fixed handling of summary when no note supplied for newblah. Again.
1282 # . Fixed detection of no form in htmltemplate Field display.
1283 #
1284 # Revision 1.10 2001/07/30 02:37:34 richard
1285 # Temporary measure until we have decent schema migration...
1286 #
1287 # Revision 1.9 2001/07/30 01:25:07 richard
1288 # Default implementation is now "classic" rather than "extended" as one would
1289 # expect.
1290 #
1291 # Revision 1.8 2001/07/29 08:27:40 richard
1292 # Fixed handling of passed-in values in form elements (ie. during a
1293 # drill-down)
1294 #
1295 # Revision 1.7 2001/07/29 07:01:39 richard
1296 # Added vim command to all source so that we don't get no steenkin' tabs :)
1297 #
1298 # Revision 1.6 2001/07/29 04:04:00 richard
1299 # Moved some code around allowing for subclassing to change behaviour.
1300 #
1301 # Revision 1.5 2001/07/28 08:16:52 richard
1302 # New issue form handles lack of note better now.
1303 #
1304 # Revision 1.4 2001/07/28 00:34:34 richard
1305 # Fixed some non-string node ids.
1306 #
1307 # Revision 1.3 2001/07/23 03:56:30 richard
1308 # oops, missed a config removal
1309 #
1310 # Revision 1.2 2001/07/22 12:09:32 richard
1311 # Final commit of Grande Splite
1312 #
1313 # Revision 1.1 2001/07/22 11:58:35 richard
1314 # More Grande Splite
1315 #
1316 #
1317 # vim: set filetype=python ts=4 sw=4 et si