Code

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