Code

4b8c7c4361a650e084d3a236a0ae38d27f080e75
[roundup.git] / roundup / cgi_client.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: cgi_client.py,v 1.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 == '+'
235             
236         return visible
238     default_index_sort = ['-activity']
239     default_index_group = ['priority']
240     default_index_filter = ['status']
241     default_index_columns = ['id','activity','title','status','assignedto']
242     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
243     def index(self):
244         ''' put up an index
245         '''
246         self.classname = 'issue'
247         # see if the web has supplied us with any customisation info
248         defaults = 1
249         for key in ':sort', ':group', ':filter', ':columns':
250             if self.form.has_key(key):
251                 defaults = 0
252                 break
253         if defaults:
254             # no info supplied - use the defaults
255             sort = self.default_index_sort
256             group = self.default_index_group
257             filter = self.default_index_filter
258             columns = self.default_index_columns
259             filterspec = self.default_index_filterspec
260         else:
261             sort = self.index_arg(':sort')
262             group = self.index_arg(':group')
263             filter = self.index_arg(':filter')
264             columns = self.index_arg(':columns')
265             filterspec = self.index_filterspec(filter)
266         return self.list(columns=columns, filter=filter, group=group,
267             sort=sort, filterspec=filterspec)
269     # XXX deviates from spec - loses the '+' (that's a reserved character
270     # in URLS
271     def list(self, sort=None, group=None, filter=None, columns=None,
272             filterspec=None, show_customization=None):
273         ''' call the template index with the args
275             :sort    - sort by prop name, optionally preceeded with '-'
276                      to give descending or nothing for ascending sorting.
277             :group   - group by prop name, optionally preceeded with '-' or
278                      to sort in descending or nothing for ascending order.
279             :filter  - selects which props should be displayed in the filter
280                      section. Default is all.
281             :columns - selects the columns that should be displayed.
282                      Default is all.
284         '''
285         cn = self.classname
286         cl = self.db.classes[cn]
287         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
288             'classname': cn, 'instancename': self.INSTANCE_NAME})
289         if sort is None: sort = self.index_arg(':sort')
290         if group is None: group = self.index_arg(':group')
291         if filter is None: filter = self.index_arg(':filter')
292         if columns is None: columns = self.index_arg(':columns')
293         if filterspec is None: filterspec = self.index_filterspec(filter)
294         if show_customization is None:
295             show_customization = self.customization_widget()
297         index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
298         index.render(filterspec, filter, columns, sort, group,
299             show_customization=show_customization)
300         self.pagefoot()
302     def shownode(self, message=None):
303         ''' display an item
304         '''
305         cn = self.classname
306         cl = self.db.classes[cn]
308         # possibly perform an edit
309         keys = self.form.keys()
310         num_re = re.compile('^\d+$')
311         # don't try to set properties if the user has just logged in
312         if keys and not self.form.has_key('__login_name'):
313             try:
314                 props, changed = parsePropsFromForm(self.db, cl, self.form,
315                     self.nodeid)
317                 # set status to chatting if 'unread' or 'resolved'
318                 if 'status' not in changed:
319                     try:
320                         # determine the id of 'unread','resolved' and 'chatting'
321                         unread_id = self.db.status.lookup('unread')
322                         resolved_id = self.db.status.lookup('resolved')
323                         chatting_id = self.db.status.lookup('chatting')
324                     except KeyError:
325                         pass
326                     else:
327                         if (not props.has_key('status') or
328                                 props['status'] == unread_id or
329                                 props['status'] == resolved_id):
330                             props['status'] = chatting_id
331                             changed.append('status')
332                 note = None
333                 if self.form.has_key('__note'):
334                     note = self.form['__note']
335                     note = note.value
336                 if changed or note:
337                     cl.set(self.nodeid, **props)
338                     self._post_editnode(self.nodeid, changed)
339                     # and some nice feedback for the user
340                     message = '%s edited ok'%', '.join(changed)
341                 else:
342                     message = 'nothing changed'
343             except:
344                 s = StringIO.StringIO()
345                 traceback.print_exc(None, s)
346                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
348         # now the display
349         id = self.nodeid
350         if cl.getkey():
351             id = cl.get(id, cl.getkey())
352         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
354         nodeid = self.nodeid
356         # use the template to display the item
357         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
358         item.render(nodeid)
360         self.pagefoot()
361     showissue = shownode
362     showmsg = shownode
364     def showuser(self, message=None):
365         '''Display a user page for editing. Make sure the user is allowed
366             to edit this node, and also check for password changes.
367         '''
368         if self.user == 'anonymous':
369             raise Unauthorised
371         user = self.db.user
373         # get the username of the node being edited
374         node_user = user.get(self.nodeid, 'username')
376         if self.user not in ('admin', node_user):
377             raise Unauthorised
379         #
380         # perform any editing
381         #
382         keys = self.form.keys()
383         num_re = re.compile('^\d+$')
384         if keys:
385             try:
386                 props, changed = parsePropsFromForm(self.db, user, self.form,
387                     self.nodeid)
388                 set_cookie = 0
389                 if self.nodeid == self.getuid() and 'password' in changed:
390                     password = self.form['password'].value.strip()
391                     if password:
392                         set_cookie = password
393                     else:
394                         del props['password']
395                         del changed[changed.index('password')]
396                 user.set(self.nodeid, **props)
397                 self._post_editnode(self.nodeid, changed)
398                 # and some feedback for the user
399                 message = '%s edited ok'%', '.join(changed)
400             except:
401                 s = StringIO.StringIO()
402                 traceback.print_exc(None, s)
403                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
404         else:
405             set_cookie = 0
407         # fix the cookie if the password has changed
408         if set_cookie:
409             self.set_cookie(self.user, set_cookie)
411         #
412         # now the display
413         #
414         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
416         # use the template to display the item
417         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
418         item.render(self.nodeid)
419         self.pagefoot()
421     def showfile(self):
422         ''' display a file
423         '''
424         nodeid = self.nodeid
425         cl = self.db.file
426         mime_type = cl.get(nodeid, 'type')
427         if mime_type == 'message/rfc822':
428             mime_type = 'text/plain'
429         self.header(headers={'Content-Type': mime_type})
430         self.write(cl.get(nodeid, 'content'))
432     def _createnode(self):
433         ''' create a node based on the contents of the form
434         '''
435         cl = self.db.classes[self.classname]
436         props, dummy = parsePropsFromForm(self.db, cl, self.form)
437         return cl.create(**props)
439     def _post_editnode(self, nid, changes=None):
440         ''' do the linking and message sending part of the node creation
441         '''
442         cn = self.classname
443         cl = self.db.classes[cn]
444         # link if necessary
445         keys = self.form.keys()
446         for key in keys:
447             if key == ':multilink':
448                 value = self.form[key].value
449                 if type(value) != type([]): value = [value]
450                 for value in value:
451                     designator, property = value.split(':')
452                     link, nodeid = roundupdb.splitDesignator(designator)
453                     link = self.db.classes[link]
454                     value = link.get(nodeid, property)
455                     value.append(nid)
456                     link.set(nodeid, **{property: value})
457             elif key == ':link':
458                 value = self.form[key].value
459                 if type(value) != type([]): value = [value]
460                 for value in value:
461                     designator, property = value.split(':')
462                     link, nodeid = roundupdb.splitDesignator(designator)
463                     link = self.db.classes[link]
464                     link.set(nodeid, **{property: nid})
466         # handle file attachments
467         files = []
468         if self.form.has_key('__file'):
469             file = self.form['__file']
470             if file.filename:
471                 mime_type = mimetypes.guess_type(file.filename)[0]
472                 if not mime_type:
473                     mime_type = "application/octet-stream"
474                 # create the new file entry
475                 files.append(self.db.file.create(type=mime_type,
476                     name=file.filename, content=file.file.read()))
478         # generate an edit message
479         # don't bother if there's no messages or nosy list 
480         props = cl.getprops()
481         note = None
482         if self.form.has_key('__note'):
483             note = self.form['__note']
484             note = note.value
485         send = len(cl.get(nid, 'nosy', [])) or note
486         if (send and props.has_key('messages') and
487                 isinstance(props['messages'], hyperdb.Multilink) and
488                 props['messages'].classname == 'msg'):
490             # handle the note
491             edit_msg = 'This %s has been edited through the web.\n'%cn
492             if note:
493                 if '\n' in note:
494                     summary = re.split(r'\n\r?', note)[0]
495                 else:
496                     summary = note
497                 m = [edit_msg + '%s\n'%note]
498             else:
499                 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.
540         
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.
589         
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
1042 # $Log: not supported by cvs2svn $
1043 # Revision 1.64  2001/11/26 22:56:35  richard
1044 # typo
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.
1055 # Fixed:
1056 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1058 # Revision 1.62  2001/11/24 00:45:42  jhermann
1059 # typeof() instead of type(): avoid clash with database field(?) "type"
1061 # Fixes this traceback:
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
1070 # Revision 1.61  2001/11/22 15:46:42  jhermann
1071 # Added module docstrings to all modules.
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
1077 # Revision 1.59  2001/11/21 03:21:13  richard
1078 # oops
1080 # Revision 1.58  2001/11/21 03:11:28  richard
1081 # Better handling of new properties.
1083 # Revision 1.57  2001/11/15 10:24:27  richard
1084 # handle the case where there is no file attached
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
1089 # Revision 1.55  2001/11/07 02:34:06  jhermann
1090 # Handling of damaged login cookies
1092 # Revision 1.54  2001/11/07 01:16:12  richard
1093 # Remove the '=' padding from cookie value so quoting isn't an issue.
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
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
1105 # Revision 1.51  2001/11/06 22:00:34  jhermann
1106 # Get debug level from ROUNDUP_DEBUG env var
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).
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.
1120 # Revision 1.48  2001/11/03 01:30:18  richard
1121 # Oops. uses pagefoot now.
1123 # Revision 1.47  2001/11/03 01:29:28  richard
1124 # Login page didn't have all close tags.
1126 # Revision 1.46  2001/11/03 01:26:55  richard
1127 # possibly fix truncated base64'ed user:pass
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
1135 # Revision 1.44  2001/10/28 23:03:08  richard
1136 # Added more useful header to the classic schema.
1138 # Revision 1.43  2001/10/24 00:01:42  richard
1139 # More fixes to lockout logic.
1141 # Revision 1.42  2001/10/23 23:56:03  richard
1142 # HTML typo
1144 # Revision 1.41  2001/10/23 23:52:35  richard
1145 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1147 # Revision 1.40  2001/10/23 23:06:39  richard
1148 # Some cleanup.
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.
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.
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.
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.
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.)
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.
1181 # Revision 1.33  2001/10/17 00:18:41  richard
1182 # Manually constructing cookie headers now.
1184 # Revision 1.32  2001/10/16 03:36:21  richard
1185 # CGI interface wasn't handling checkboxes at all.
1187 # Revision 1.31  2001/10/14 10:55:00  richard
1188 # Handle empty strings in HTML template Link function
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.
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.
1199 # Revision 1.28  2001/10/08 00:34:31  richard
1200 # Change message was stuffing up for multilinks with no key property.
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.
1222 # Revision 1.26  2001/09/12 08:31:42  richard
1223 # handle cases where mime type is not guessable
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.
1228 # Revision 1.24  2001/08/29 04:49:39  richard
1229 # didn't clean up fully after debugging :(
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).
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.
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.
1243 # Revision 1.20  2001/08/12 06:32:36  richard
1244 # using isinstance(blah, Foo) now instead of isFooType
1246 # Revision 1.19  2001/08/07 00:24:42  richard
1247 # stupid typo
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.
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).
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.
1263 # Revision 1.15  2001/08/02 00:34:10  richard
1264 # bleah syntax error
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.
1269 # Revision 1.13  2001/07/30 08:12:17  richard
1270 # Added time logging and file uploading to the templates.
1272 # Revision 1.12  2001/07/30 06:26:31  richard
1273 # Added some documentation on how the newblah works.
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.
1284 # Revision 1.10  2001/07/30 02:37:34  richard
1285 # Temporary measure until we have decent schema migration...
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.
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)
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 :)
1298 # Revision 1.6  2001/07/29 04:04:00  richard
1299 # Moved some code around allowing for subclassing to change behaviour.
1301 # Revision 1.5  2001/07/28 08:16:52  richard
1302 # New issue form handles lack of note better now.
1304 # Revision 1.4  2001/07/28 00:34:34  richard
1305 # Fixed some non-string node ids.
1307 # Revision 1.3  2001/07/23 03:56:30  richard
1308 # oops, missed a config removal
1310 # Revision 1.2  2001/07/22 12:09:32  richard
1311 # Final commit of Grande Splite
1313 # Revision 1.1  2001/07/22 11:58:35  richard
1314 # More Grande Splite
1317 # vim: set filetype=python ts=4 sw=4 et si