Code

Not sure why the cgi_client had an indexer argument.
[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.131 2002-07-08 06:53:57 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
25 import binascii, Cookie, time, random
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.
47     '''
49     def __init__(self, instance, request, env, form=None):
50         hyperdb.traceMark()
51         self.instance = instance
52         self.request = request
53         self.env = env
54         self.path = env['PATH_INFO']
55         self.split_path = self.path.split('/')
56         self.instance_path_name = env['INSTANCE_NAME']
57         url = self.env['SCRIPT_NAME'] + '/'
58         machine = self.env['SERVER_NAME']
59         port = self.env['SERVER_PORT']
60         if port != '80': machine = machine + ':' + port
61         self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
62             None, None, None))
64         if form is None:
65             self.form = cgi.FieldStorage(environ=env)
66         else:
67             self.form = form
68         self.headers_done = 0
69         try:
70             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
71         except ValueError:
72             # someone gave us a non-int debug level, turn it off
73             self.debug = 0
75     def getuid(self):
76         try:
77             return self.db.user.lookup(self.user)
78         except KeyError:
79             if self.user is None:
80                 # user is not logged in and username 'anonymous' doesn't
81                 # exist in the database
82                 err = _('anonymous users have read-only access only')
83             else:
84                 err = _("sanity check: unknown user name `%s'")%self.user
85             raise Unauthorised, errmsg
87     def header(self, headers=None):
88         '''Put up the appropriate header.
89         '''
90         if headers is None:
91             headers = {'Content-Type':'text/html'}
92         if not headers.has_key('Content-Type'):
93             headers['Content-Type'] = 'text/html'
94         self.request.send_response(200)
95         for entry in headers.items():
96             self.request.send_header(*entry)
97         self.request.end_headers()
98         self.headers_done = 1
99         if self.debug:
100             self.headers_sent = headers
102     global_javascript = '''
103 <script language="javascript">
104 submitted = false;
105 function submit_once() {
106     if (submitted) {
107         alert("Your request is being processed.\\nPlease be patient.");
108         return 0;
109     }
110     submitted = true;
111     return 1;
114 function help_window(helpurl, width, height) {
115     HelpWin = window.open('%(base)s%(instance_path_name)s/' + helpurl, 'HelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
118 </script>
119 '''
120     def make_index_link(self, name):
121         '''Turn a configuration entry into a hyperlink...
122         '''
123         # get the link label and spec
124         spec = getattr(self.instance, name+'_INDEX')
126         d = {}
127         d[':sort'] = ','.join(map(urllib.quote, spec['SORT']))
128         d[':group'] = ','.join(map(urllib.quote, spec['GROUP']))
129         d[':filter'] = ','.join(map(urllib.quote, spec['FILTER']))
130         d[':columns'] = ','.join(map(urllib.quote, spec['COLUMNS']))
132         # snarf the filterspec
133         filterspec = spec['FILTERSPEC'].copy()
135         # now format the filterspec
136         for k, l in filterspec.items():
137             # fix up the CURRENT USER if needed (handle None too since that's
138             # the old flag value)
139             if l in (None, 'CURRENT USER'):
140                 if not self.user:
141                     continue
142                 l = [self.db.user.lookup(self.user)]
144             # add
145             d[urllib.quote(k)] = ','.join(map(urllib.quote, l))
147         # finally, format the URL
148         return '<a href="%s?%s">%s</a>'%(spec['CLASS'],
149             '&'.join([k+'='+v for k,v in d.items()]), spec['LABEL'])
152     def pagehead(self, title, message=None):
153         '''Display the page heading, with information about the tracker and
154             links to more information
155         '''
157         # include any important message
158         if message is not None:
159             message = _('<div class="system-msg">%(message)s</div>')%locals()
160         else:
161             message = ''
163         # style sheet (CSS)
164         style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
166         # figure who the user is
167         user_name = self.user or ''
168         if user_name not in ('', 'anonymous'):
169             userid = self.db.user.lookup(self.user)
170         else:
171             userid = None
173         # figure all the header links
174         if hasattr(self.instance, 'HEADER_INDEX_LINKS'):
175             links = []
176             for name in self.instance.HEADER_INDEX_LINKS:
177                 spec = getattr(self.instance, name + '_INDEX')
178                 # skip if we need to fill in the logged-in user id there's
179                 # no user logged in
180                 if (spec['FILTERSPEC'].has_key('assignedto') and
181                         spec['FILTERSPEC']['assignedto'] in ('CURRENT USER',
182                         None) and userid is None):
183                     continue
184                 links.append(self.make_index_link(name))
185         else:
186             # no config spec - hard-code
187             links = [
188                 _('All <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>'),
189                 _('Unassigned <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>')
190             ]
192         # if they're logged in, include links to their information, and the
193         # ability to add an issue
194         if user_name not in ('', 'anonymous'):
195             user_info = _('''
196 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
197 ''')%locals()
199             # figure the "add class" links
200             if hasattr(self.instance, 'HEADER_ADD_LINKS'):
201                 classes = self.instance.HEADER_ADD_LINKS
202             else:
203                 classes = ['issue']
204             l = []
205             for class_name in classes:
206                 cap_class = class_name.capitalize()
207                 links.append(_('Add <a href="new%(class_name)s">'
208                     '%(cap_class)s</a>')%locals())
210             # if there's no config header link spec, force a user link here
211             if not hasattr(self.instance, 'HEADER_INDEX_LINKS'):
212                 links.append(_('<a href="issue?assignedto=%(userid)s&status=-1,unread,chatting,open,pending&:filter=status,resolution,assignedto&:sort=-activity&:columns=id,activity,status,resolution,title,creator&:group=type&show_customization=1">My Issues</a>')%locals())
213         else:
214             user_info = _('<a href="login">Login</a>')
215             add_links = ''
217         # if the user is admin, include admin links
218         admin_links = ''
219         if user_name == 'admin':
220             links.append(_('<a href="list_classes">Class List</a>'))
221             links.append(_('<a href="user">User List</a>'))
222             links.append(_('<a href="newuser">Add User</a>'))
224         # add the search links
225         if hasattr(self.instance, 'HEADER_SEARCH_LINKS'):
226             classes = self.instance.HEADER_SEARCH_LINKS
227         else:
228             classes = ['issue']
229         l = []
230         for class_name in classes:
231             cap_class = class_name.capitalize()
232             links.append(_('Search <a href="search%(class_name)s">'
233                 '%(cap_class)s</a>')%locals())
235         # now we have all the links, join 'em
236         links = '\n | '.join(links)
238         # include the javascript bit
239         global_javascript = self.global_javascript%self.__dict__
241         # finally, format the header
242         self.write(_('''<html><head>
243 <title>%(title)s</title>
244 <style type="text/css">%(style)s</style>
245 </head>
246 %(global_javascript)s
247 <body bgcolor=#ffffff>
248 %(message)s
249 <table width=100%% border=0 cellspacing=0 cellpadding=2>
250 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
251 <td align=right valign=bottom>%(user_name)s</td></tr>
252 <tr class="location-bar">
253 <td align=left>%(links)s</td>
254 <td align=right>%(user_info)s</td>
255 </table>
256 ''')%locals())
258     def pagefoot(self):
259         if self.debug:
260             self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
261             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
262             keys = self.form.keys()
263             keys.sort()
264             if keys:
265                 self.write(_('<dt><b>Form entries</b></dt>'))
266                 for k in self.form.keys():
267                     v = self.form.getvalue(k, "<empty>")
268                     if type(v) is type([]):
269                         # Multiple username fields specified
270                         v = "|".join(v)
271                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
272             keys = self.headers_sent.keys()
273             keys.sort()
274             self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
275             for k in keys:
276                 v = self.headers_sent[k]
277                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
278             keys = self.env.keys()
279             keys.sort()
280             self.write(_('<dt><b>CGI environment</b></dt>'))
281             for k in keys:
282                 v = self.env[k]
283                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
284             self.write('</dl></small>')
285         self.write('</body></html>')
287     def write(self, content):
288         if not self.headers_done:
289             self.header()
290         self.request.wfile.write(content)
292     def index_arg(self, arg):
293         ''' handle the args to index - they might be a list from the form
294             (ie. submitted from a form) or they might be a command-separated
295             single string (ie. manually constructed GET args)
296         '''
297         if self.form.has_key(arg):
298             arg =  self.form[arg]
299             if type(arg) == type([]):
300                 return [arg.value for arg in arg]
301             return arg.value.split(',')
302         return []
304     def index_filterspec(self, filter):
305         ''' pull the index filter spec from the form
307         Links and multilinks want to be lists - the rest are straight
308         strings.
309         '''
310         props = self.db.classes[self.classname].getprops()
311         # all the form args not starting with ':' are filters
312         filterspec = {}
313         for key in self.form.keys():
314             if key[0] == ':': continue
315             if not props.has_key(key): continue
316             if key not in filter: continue
317             prop = props[key]
318             value = self.form[key]
319             if (isinstance(prop, hyperdb.Link) or
320                     isinstance(prop, hyperdb.Multilink)):
321                 if type(value) == type([]):
322                     value = [arg.value for arg in value]
323                 else:
324                     value = value.value.split(',')
325                 l = filterspec.get(key, [])
326                 l = l + value
327                 filterspec[key] = l
328             else:
329                 filterspec[key] = value.value
330         return filterspec
332     def customization_widget(self):
333         ''' The customization widget is visible by default. The widget
334             visibility is remembered by show_customization.  Visibility
335             is not toggled if the action value is "Redisplay"
336         '''
337         if not self.form.has_key('show_customization'):
338             visible = 1
339         else:
340             visible = int(self.form['show_customization'].value)
341             if self.form.has_key('action'):
342                 if self.form['action'].value != 'Redisplay':
343                     visible = self.form['action'].value == '+'
344             
345         return visible
347     # TODO: make this go away some day...
348     default_index_sort = ['-activity']
349     default_index_group = ['priority']
350     default_index_filter = ['status']
351     default_index_columns = ['id','activity','title','status','assignedto']
352     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
354     def _get_customisation_info(self):
355         # see if the web has supplied us with any customisation info
356         defaults = 1
357         for key in ':sort', ':group', ':filter', ':columns':
358             if self.form.has_key(key):
359                 defaults = 0
360                 break
361         if defaults:
362             # try the instance config first
363             if hasattr(self.instance, 'DEFAULT_INDEX'):
364                 d = self.instance.DEFAULT_INDEX
365                 self.classname = d['CLASS']
366                 sort = d['SORT']
367                 group = d['GROUP']
368                 filter = d['FILTER']
369                 columns = d['COLUMNS']
370                 filterspec = d['FILTERSPEC']
372             else:
373                 # nope - fall back on the old way of doing it
374                 self.classname = 'issue'
375                 sort = self.default_index_sort
376                 group = self.default_index_group
377                 filter = self.default_index_filter
378                 columns = self.default_index_columns
379                 filterspec = self.default_index_filterspec
380         else:
381             # make list() extract the info from the CGI environ
382             self.classname = 'issue'
383             sort = group = filter = columns = filterspec = None
384         return columns, filter, group, sort, filterspec
386     def index(self):
387         ''' put up an index - no class specified
388         '''
389         columns, filter, group, sort, filterspec = \
390             self._get_customisation_info()
391         return self.list(columns=columns, filter=filter, group=group,
392             sort=sort, filterspec=filterspec)
394     def searchnode(self):
395         columns, filter, group, sort, filterspec = \
396             self._get_customisation_info()
397         show_nodes = 1
398         if len(self.form.keys()) == 0:
399             # get the default search filters from instance_config
400             if hasattr(self.instance, 'SEARCH_FILTERS'):
401                 for f in self.instance.SEARCH_FILTERS:
402                     spec = getattr(self.instance, f)
403                     if spec['CLASS'] == self.classname:
404                         filter = spec['FILTER']
405                 
406             show_nodes = 0
407             show_customization = 1
408         return self.list(columns=columns, filter=filter, group=group,
409             sort=sort, filterspec=filterspec,
410             show_customization=show_customization, show_nodes=show_nodes)
411         
413     # XXX deviates from spec - loses the '+' (that's a reserved character
414     # in URLS
415     def list(self, sort=None, group=None, filter=None, columns=None,
416             filterspec=None, show_customization=None, show_nodes=1):
417         ''' call the template index with the args
419             :sort    - sort by prop name, optionally preceeded with '-'
420                      to give descending or nothing for ascending sorting.
421             :group   - group by prop name, optionally preceeded with '-' or
422                      to sort in descending or nothing for ascending order.
423             :filter  - selects which props should be displayed in the filter
424                      section. Default is all.
425             :columns - selects the columns that should be displayed.
426                      Default is all.
428         '''
429         cn = self.classname
430         cl = self.db.classes[cn]
431         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
432             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
433         if sort is None: sort = self.index_arg(':sort')
434         if group is None: group = self.index_arg(':group')
435         if filter is None: filter = self.index_arg(':filter')
436         if columns is None: columns = self.index_arg(':columns')
437         if filterspec is None: filterspec = self.index_filterspec(filter)
438         if show_customization is None:
439             show_customization = self.customization_widget()
440         if self.form.has_key('search_text'):
441             search_text = self.form['search_text'].value
442         else:
443             search_text = ''
445         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
446         try:
447             index.render(filterspec, search_text, filter, columns, sort, 
448                 group, show_customization=show_customization, 
449                 show_nodes=show_nodes)
450         except htmltemplate.MissingTemplateError:
451             self.basicClassEditPage()
452         self.pagefoot()
454     def basicClassEditPage(self):
455         '''Display a basic edit page that allows simple editing of the
456            nodes of the current class
457         '''
458         if self.user != 'admin':
459             raise Unauthorised
460         w = self.write
461         cn = self.classname
462         cl = self.db.classes[cn]
463         idlessprops = cl.getprops(protected=0).keys()
464         props = ['id'] + idlessprops
467         # get the CSV module
468         try:
469             import csv
470         except ImportError:
471             w(_('Sorry, you need the csv module to use this function.<br>\n'
472                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
473             return
475         # do the edit
476         if self.form.has_key('rows'):
477             rows = self.form['rows'].value.splitlines()
478             p = csv.parser()
479             found = {}
480             line = 0
481             for row in rows:
482                 line += 1
483                 values = p.parse(row)
484                 # not a complete row, keep going
485                 if not values: continue
487                 # extract the nodeid
488                 nodeid, values = values[0], values[1:]
489                 found[nodeid] = 1
491                 # confirm correct weight
492                 if len(idlessprops) != len(values):
493                     w(_('Not enough values on line %(line)s'%{'line':line}))
494                     return
496                 # extract the new values
497                 d = {}
498                 for name, value in zip(idlessprops, values):
499                     d[name] = value.strip()
501                 # perform the edit
502                 if cl.hasnode(nodeid):
503                     # edit existing
504                     cl.set(nodeid, **d)
505                 else:
506                     # new node
507                     found[cl.create(**d)] = 1
509             # retire the removed entries
510             for nodeid in cl.list():
511                 if not found.has_key(nodeid):
512                     cl.retire(nodeid)
514         w(_('''<p class="form-help">You may edit the contents of the
515         "%(classname)s" class using this form. The lines are full-featured
516         Comma-Separated-Value lines, so you may include commas and even
517         newlines by enclosing the values in double-quotes ("). Double
518         quotes themselves must be quoted by doubling ("").</p>
519         <p class="form-help">Remove entries by deleting their line. Add
520         new entries by appending
521         them to the table - put an X in the id column.</p>''')%{'classname':cn})
523         l = []
524         for name in props:
525             l.append(name)
526         w('<tt>')
527         w(', '.join(l) + '\n')
528         w('</tt>')
530         w('<form onSubmit="return submit_once()" method="POST">')
531         w('<textarea name="rows" cols=80 rows=15>')
532         p = csv.parser()
533         for nodeid in cl.list():
534             l = []
535             for name in props:
536                 l.append(cgi.escape(str(cl.get(nodeid, name))))
537             w(p.join(l) + '\n')
539         w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
541     def classhelp(self):
542         '''Display a table of class info
543         '''
544         w = self.write
545         cn = self.form['classname'].value
546         cl = self.db.classes[cn]
547         props = self.form['properties'].value.split(',')
549         w('<table border=1 cellspacing=0 cellpaddin=2>')
550         w('<tr>')
551         for name in props:
552             w('<th align=left>%s</th>'%name)
553         w('</tr>')
554         for nodeid in cl.list():
555             w('<tr>')
556             for name in props:
557                 value = cgi.escape(str(cl.get(nodeid, name)))
558                 w('<td align="left" valign="top">%s</td>'%value)
559             w('</tr>')
560         w('</table>')
562     def shownode(self, message=None, num_re=re.compile('^\d+$')):
563         ''' display an item
564         '''
565         cn = self.classname
566         cl = self.db.classes[cn]
567         if self.form.has_key(':multilink'):
568             link = self.form[':multilink'].value
569             designator, linkprop = link.split(':')
570             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
571         else:
572             xtra = ''
574         # possibly perform an edit
575         keys = self.form.keys()
576         # don't try to set properties if the user has just logged in
577         if keys and not self.form.has_key('__login_name'):
578             try:
579                 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
580                 # make changes to the node
581                 self._changenode(props)
582                 # handle linked nodes 
583                 self._post_editnode(self.nodeid)
584                 # and some nice feedback for the user
585                 if props:
586                     message = _('%(changes)s edited ok')%{'changes':
587                         ', '.join(props.keys())}
588                 elif self.form.has_key('__note') and self.form['__note'].value:
589                     message = _('note added')
590                 elif (self.form.has_key('__file') and
591                         self.form['__file'].filename):
592                     message = _('file added')
593                 else:
594                     message = _('nothing changed')
595             except:
596                 self.db.rollback()
597                 s = StringIO.StringIO()
598                 traceback.print_exc(None, s)
599                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
601         # now the display
602         id = self.nodeid
603         if cl.getkey():
604             id = cl.get(id, cl.getkey())
605         self.pagehead('%s: %s %s'%(self.classname.capitalize(), id, xtra), message)
607         nodeid = self.nodeid
609         # use the template to display the item
610         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
611             self.classname)
612         item.render(nodeid)
614         self.pagefoot()
615     showissue = shownode
616     showmsg = shownode
617     searchissue = searchnode
619     def _changenode(self, props):
620         ''' change the node based on the contents of the form
621         '''
622         cl = self.db.classes[self.classname]
624         # create the message
625         message, files = self._handle_message()
626         if message:
627             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
628         if files:
629             props['files'] = cl.get(self.nodeid, 'files') + files
631         # make the changes
632         cl.set(self.nodeid, **props)
634     def _createnode(self):
635         ''' create a node based on the contents of the form
636         '''
637         cl = self.db.classes[self.classname]
638         props = parsePropsFromForm(self.db, cl, self.form)
640         # check for messages and files
641         message, files = self._handle_message()
642         if message:
643             props['messages'] = [message]
644         if files:
645             props['files'] = files
646         # create the node and return it's id
647         return cl.create(**props)
649     def _handle_message(self):
650         ''' generate an edit message
651         '''
652         # handle file attachments 
653         files = []
654         if self.form.has_key('__file'):
655             file = self.form['__file']
656             if file.filename:
657                 filename = file.filename.split('\\')[-1]
658                 mime_type = mimetypes.guess_type(filename)[0]
659                 if not mime_type:
660                     mime_type = "application/octet-stream"
661                 # create the new file entry
662                 files.append(self.db.file.create(type=mime_type,
663                     name=filename, content=file.file.read()))
665         # we don't want to do a message if none of the following is true...
666         cn = self.classname
667         cl = self.db.classes[self.classname]
668         props = cl.getprops()
669         note = None
670         # in a nutshell, don't do anything if there's no note or there's no
671         # NOSY
672         if self.form.has_key('__note'):
673             note = self.form['__note'].value.strip()
674         if not note:
675             return None, files
676         if not props.has_key('messages'):
677             return None, files
678         if not isinstance(props['messages'], hyperdb.Multilink):
679             return None, files
680         if not props['messages'].classname == 'msg':
681             return None, files
682         if not (self.form.has_key('nosy') or note):
683             return None, files
685         # handle the note
686         if '\n' in note:
687             summary = re.split(r'\n\r?', note)[0]
688         else:
689             summary = note
690         m = ['%s\n'%note]
692         # handle the messageid
693         # TODO: handle inreplyto
694         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
695             self.classname, self.instance.MAIL_DOMAIN)
697         # now create the message, attaching the files
698         content = '\n'.join(m)
699         message_id = self.db.msg.create(author=self.getuid(),
700             recipients=[], date=date.Date('.'), summary=summary,
701             content=content, files=files, messageid=messageid)
703         # update the messages property
704         return message_id, files
706     def _post_editnode(self, nid):
707         '''Do the linking part of the node creation.
709            If a form element has :link or :multilink appended to it, its
710            value specifies a node designator and the property on that node
711            to add _this_ node to as a link or multilink.
713            This is typically used on, eg. the file upload page to indicated
714            which issue to link the file to.
716            TODO: I suspect that this and newfile will go away now that
717            there's the ability to upload a file using the issue __file form
718            element!
719         '''
720         cn = self.classname
721         cl = self.db.classes[cn]
722         # link if necessary
723         keys = self.form.keys()
724         for key in keys:
725             if key == ':multilink':
726                 value = self.form[key].value
727                 if type(value) != type([]): value = [value]
728                 for value in value:
729                     designator, property = value.split(':')
730                     link, nodeid = roundupdb.splitDesignator(designator)
731                     link = self.db.classes[link]
732                     # take a dupe of the list so we're not changing the cache
733                     value = link.get(nodeid, property)[:]
734                     value.append(nid)
735                     link.set(nodeid, **{property: value})
736             elif key == ':link':
737                 value = self.form[key].value
738                 if type(value) != type([]): value = [value]
739                 for value in value:
740                     designator, property = value.split(':')
741                     link, nodeid = roundupdb.splitDesignator(designator)
742                     link = self.db.classes[link]
743                     link.set(nodeid, **{property: nid})
745     def newnode(self, message=None):
746         ''' Add a new node to the database.
747         
748         The form works in two modes: blank form and submission (that is,
749         the submission goes to the same URL). **Eventually this means that
750         the form will have previously entered information in it if
751         submission fails.
753         The new node will be created with the properties specified in the
754         form submission. For multilinks, multiple form entries are handled,
755         as are prop=value,value,value. You can't mix them though.
757         If the new node is to be referenced from somewhere else immediately
758         (ie. the new node is a file that is to be attached to a support
759         issue) then supply one of these arguments in addition to the usual
760         form entries:
761             :link=designator:property
762             :multilink=designator:property
763         ... which means that once the new node is created, the "property"
764         on the node given by "designator" should now reference the new
765         node's id. The node id will be appended to the multilink.
766         '''
767         cn = self.classname
768         cl = self.db.classes[cn]
769         if self.form.has_key(':multilink'):
770             link = self.form[':multilink'].value
771             designator, linkprop = link.split(':')
772             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
773         else:
774             xtra = ''
776         # possibly perform a create
777         keys = self.form.keys()
778         if [i for i in keys if i[0] != ':']:
779             props = {}
780             try:
781                 nid = self._createnode()
782                 # handle linked nodes 
783                 self._post_editnode(nid)
784                 # and some nice feedback for the user
785                 message = _('%(classname)s created ok')%{'classname': cn}
787                 # render the newly created issue
788                 self.db.commit()
789                 self.nodeid = nid
790                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
791                     message)
792                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
793                     self.classname)
794                 item.render(nid)
795                 self.pagefoot()
796                 return
797             except:
798                 self.db.rollback()
799                 s = StringIO.StringIO()
800                 traceback.print_exc(None, s)
801                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
802         self.pagehead(_('New %(classname)s %(xtra)s')%{
803                 'classname': self.classname.capitalize(),
804                 'xtra': xtra }, message)
806         # call the template
807         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
808             self.classname)
809         newitem.render(self.form)
811         self.pagefoot()
812     newissue = newnode
814     def newuser(self, message=None):
815         ''' Add a new user to the database.
817             Don't do any of the message or file handling, just create the node.
818         '''
819         cn = self.classname
820         cl = self.db.classes[cn]
822         # possibly perform a create
823         keys = self.form.keys()
824         if [i for i in keys if i[0] != ':']:
825             try:
826                 props = parsePropsFromForm(self.db, cl, self.form)
827                 nid = cl.create(**props)
828                 # handle linked nodes 
829                 self._post_editnode(nid)
830                 # and some nice feedback for the user
831                 message = _('%(classname)s created ok')%{'classname': cn}
832             except:
833                 self.db.rollback()
834                 s = StringIO.StringIO()
835                 traceback.print_exc(None, s)
836                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
837         self.pagehead(_('New %(classname)s')%{'classname':
838              self.classname.capitalize()}, message)
840         # call the template
841         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
842             self.classname)
843         newitem.render(self.form)
845         self.pagefoot()
847     def newfile(self, message=None):
848         ''' Add a new file to the database.
849         
850         This form works very much the same way as newnode - it just has a
851         file upload.
852         '''
853         cn = self.classname
854         cl = self.db.classes[cn]
855         props = parsePropsFromForm(self.db, cl, self.form)
856         if self.form.has_key(':multilink'):
857             link = self.form[':multilink'].value
858             designator, linkprop = link.split(':')
859             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
860         else:
861             xtra = ''
863         # possibly perform a create
864         keys = self.form.keys()
865         if [i for i in keys if i[0] != ':']:
866             try:
867                 file = self.form['content']
868                 mime_type = mimetypes.guess_type(file.filename)[0]
869                 if not mime_type:
870                     mime_type = "application/octet-stream"
871                 # save the file
872                 props['type'] = mime_type
873                 props['name'] = file.filename
874                 props['content'] = file.file.read()
875                 nid = cl.create(**props)
876                 # handle linked nodes
877                 self._post_editnode(nid)
878                 # and some nice feedback for the user
879                 message = _('%(classname)s created ok')%{'classname': cn}
880             except:
881                 self.db.rollback()
882                 s = StringIO.StringIO()
883                 traceback.print_exc(None, s)
884                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
886         self.pagehead(_('New %(classname)s %(xtra)s')%{
887                 'classname': self.classname.capitalize(),
888                 'xtra': xtra }, message)
889         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
890             self.classname)
891         newitem.render(self.form)
892         self.pagefoot()
894     def showuser(self, message=None, num_re=re.compile('^\d+$')):
895         '''Display a user page for editing. Make sure the user is allowed
896             to edit this node, and also check for password changes.
897         '''
898         if self.user == 'anonymous':
899             raise Unauthorised
901         user = self.db.user
903         # get the username of the node being edited
904         node_user = user.get(self.nodeid, 'username')
906         if self.user not in ('admin', node_user):
907             raise Unauthorised
909         #
910         # perform any editing
911         #
912         keys = self.form.keys()
913         if keys:
914             try:
915                 props = parsePropsFromForm(self.db, user, self.form,
916                     self.nodeid)
917                 set_cookie = 0
918                 if props.has_key('password'):
919                     password = self.form['password'].value.strip()
920                     if not password:
921                         # no password was supplied - don't change it
922                         del props['password']
923                     elif self.nodeid == self.getuid():
924                         # this is the logged-in user's password
925                         set_cookie = password
926                 user.set(self.nodeid, **props)
927                 # and some feedback for the user
928                 message = _('%(changes)s edited ok')%{'changes':
929                     ', '.join(props.keys())}
930             except:
931                 self.db.rollback()
932                 s = StringIO.StringIO()
933                 traceback.print_exc(None, s)
934                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
935         else:
936             set_cookie = 0
938         # fix the cookie if the password has changed
939         if set_cookie:
940             self.set_cookie(self.user, set_cookie)
942         #
943         # now the display
944         #
945         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
947         # use the template to display the item
948         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
949         item.render(self.nodeid)
950         self.pagefoot()
952     def showfile(self):
953         ''' display a file
954         '''
955         nodeid = self.nodeid
956         cl = self.db.classes[self.classname]
957         mime_type = cl.get(nodeid, 'type')
958         if mime_type == 'message/rfc822':
959             mime_type = 'text/plain'
960         self.header(headers={'Content-Type': mime_type})
961         self.write(cl.get(nodeid, 'content'))
963     def classes(self, message=None):
964         ''' display a list of all the classes in the database
965         '''
966         if self.user == 'admin':
967             self.pagehead(_('Table of classes'), message)
968             classnames = self.db.classes.keys()
969             classnames.sort()
970             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
971             for cn in classnames:
972                 cl = self.db.getclass(cn)
973                 self.write('<tr class="list-header"><th colspan=2 align=left>'
974                     '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
975                 for key, value in cl.properties.items():
976                     if value is None: value = ''
977                     else: value = str(value)
978                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
979                         key, cgi.escape(value)))
980             self.write('</table>')
981             self.pagefoot()
982         else:
983             raise Unauthorised
985     def login(self, message=None, newuser_form=None, action='index'):
986         '''Display a login page.
987         '''
988         self.pagehead(_('Login to roundup'), message)
989         self.write(_('''
990 <table>
991 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
992 <form onSubmit="return submit_once()" action="login_action" method=POST>
993 <input type="hidden" name="__destination_url" value="%(action)s">
994 <tr><td align=right>Login name: </td>
995     <td><input name="__login_name"></td></tr>
996 <tr><td align=right>Password: </td>
997     <td><input type="password" name="__login_password"></td></tr>
998 <tr><td></td>
999     <td><input type="submit" value="Log In"></td></tr>
1000 </form>
1001 ''')%locals())
1002         if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
1003             self.write('</table>')
1004             self.pagefoot()
1005             return
1006         values = {'realname': '', 'organisation': '', 'address': '',
1007             'phone': '', 'username': '', 'password': '', 'confirm': '',
1008             'action': action, 'alternate_addresses': ''}
1009         if newuser_form is not None:
1010             for key in newuser_form.keys():
1011                 values[key] = newuser_form[key].value
1012         self.write(_('''
1013 <p>
1014 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1015 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1016 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1017 <input type="hidden" name="__destination_url" value="%(action)s">
1018 <tr><td align=right><em>Name: </em></td>
1019     <td><input name="realname" value="%(realname)s" size=40></td></tr>
1020 <tr><td align=right><em>Organisation: </em></td>
1021     <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1022 <tr><td align=right>E-Mail Address: </td>
1023     <td><input name="address" value="%(address)s" size=40></td></tr>
1024 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1025     <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1026 <tr><td align=right><em>Phone: </em></td>
1027     <td><input name="phone" value="%(phone)s"></td></tr>
1028 <tr><td align=right>Preferred Login name: </td>
1029     <td><input name="username" value="%(username)s"></td></tr>
1030 <tr><td align=right>Password: </td>
1031     <td><input type="password" name="password" value="%(password)s"></td></tr>
1032 <tr><td align=right>Password Again: </td>
1033     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1034 <tr><td></td>
1035     <td><input type="submit" value="Register"></td></tr>
1036 </form>
1037 </table>
1038 ''')%values)
1039         self.pagefoot()
1041     def login_action(self, message=None):
1042         '''Attempt to log a user in and set the cookie
1044         returns 0 if a page is generated as a result of this call, and
1045         1 if not (ie. the login is successful
1046         '''
1047         if not self.form.has_key('__login_name'):
1048             self.login(message=_('Username required'))
1049             return 0
1050         self.user = self.form['__login_name'].value
1051         if self.form.has_key('__login_password'):
1052             password = self.form['__login_password'].value
1053         else:
1054             password = ''
1055         # make sure the user exists
1056         try:
1057             uid = self.db.user.lookup(self.user)
1058         except KeyError:
1059             name = self.user
1060             self.make_user_anonymous()
1061             action = self.form['__destination_url'].value
1062             self.login(message=_('No such user "%(name)s"')%locals(),
1063                 action=action)
1064             return 0
1066         # and that the password is correct
1067         pw = self.db.user.get(uid, 'password')
1068         if password != pw:
1069             self.make_user_anonymous()
1070             action = self.form['__destination_url'].value
1071             self.login(message=_('Incorrect password'), action=action)
1072             return 0
1074         self.set_cookie(self.user, password)
1075         return 1
1077     def newuser_action(self, message=None):
1078         '''Attempt to create a new user based on the contents of the form
1079         and then set the cookie.
1081         return 1 on successful login
1082         '''
1083         # re-open the database as "admin"
1084         self.db = self.instance.open('admin')
1086         # TODO: pre-check the required fields and username key property
1087         cl = self.db.user
1088         try:
1089             props = parsePropsFromForm(self.db, cl, self.form)
1090             uid = cl.create(**props)
1091         except ValueError, message:
1092             action = self.form['__destination_url'].value
1093             self.login(message, action=action)
1094             return 0
1095         self.user = cl.get(uid, 'username')
1096         password = cl.get(uid, 'password')
1097         self.set_cookie(self.user, self.form['password'].value)
1098         return 1
1100     def set_cookie(self, user, password):
1101         # construct the cookie
1102         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
1103         if user[-1] == '=':
1104           if user[-2] == '=':
1105             user = user[:-2]
1106           else:
1107             user = user[:-1]
1108         expire = Cookie._getdate(86400*365)
1109         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1110         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
1111             user, expire, path)})
1113     def make_user_anonymous(self):
1114         # make us anonymous if we can
1115         try:
1116             self.db.user.lookup('anonymous')
1117             self.user = 'anonymous'
1118         except KeyError:
1119             self.user = None
1121     def logout(self, message=None):
1122         self.make_user_anonymous()
1123         # construct the logout cookie
1124         now = Cookie._getdate()
1125         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
1126         self.header({'Set-Cookie':
1127             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1128             path)})
1129         self.login()
1131     def main(self):
1132         '''Wrap the database accesses so we can close the database cleanly
1133         '''
1134         # determine the uid to use
1135         self.db = self.instance.open('admin')
1136         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1137         user = 'anonymous'
1138         if (cookie.has_key('roundup_user') and
1139                 cookie['roundup_user'].value != 'deleted'):
1140             cookie = cookie['roundup_user'].value
1141             if len(cookie)%4:
1142               cookie = cookie + '='*(4-len(cookie)%4)
1143             try:
1144                 user, password = binascii.a2b_base64(cookie).split(':')
1145             except (TypeError, binascii.Error, binascii.Incomplete):
1146                 # damaged cookie!
1147                 user, password = 'anonymous', ''
1149             # make sure the user exists
1150             try:
1151                 uid = self.db.user.lookup(user)
1152                 # now validate the password
1153                 if password != self.db.user.get(uid, 'password'):
1154                     user = 'anonymous'
1155             except KeyError:
1156                 user = 'anonymous'
1158         # make sure the anonymous user is valid if we're using it
1159         if user == 'anonymous':
1160             self.make_user_anonymous()
1161         else:
1162             self.user = user
1164         # re-open the database for real, using the user
1165         self.db = self.instance.open(self.user)
1167         # now figure which function to call
1168         path = self.split_path
1170         # default action to index if the path has no information in it
1171         if not path or path[0] in ('', 'index'):
1172             action = 'index'
1173         else:
1174             action = path[0]
1176         # Everthing ignores path[1:]
1177         #  - The file download link generator actually relies on this - it
1178         #    appends the name of the file to the URL so the download file name
1179         #    is correct, but doesn't actually use it.
1181         # everyone is allowed to try to log in
1182         if action == 'login_action':
1183             # try to login
1184             if not self.login_action():
1185                 return
1186             # figure the resulting page
1187             action = self.form['__destination_url'].value
1188             if not action:
1189                 action = 'index'
1190             self.do_action(action)
1191             return
1193         # allow anonymous people to register
1194         if action == 'newuser_action':
1195             # if we don't have a login and anonymous people aren't allowed to
1196             # register, then spit up the login form
1197             if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1198                 if action == 'login':
1199                     self.login()         # go to the index after login
1200                 else:
1201                     self.login(action=action)
1202                 return
1203             # try to add the user
1204             if not self.newuser_action():
1205                 return
1206             # figure the resulting page
1207             action = self.form['__destination_url'].value
1208             if not action:
1209                 action = 'index'
1211         # no login or registration, make sure totally anonymous access is OK
1212         elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1213             if action == 'login':
1214                 self.login()             # go to the index after login
1215             else:
1216                 self.login(action=action)
1217             return
1219         # just a regular action
1220         self.do_action(action)
1222         # commit all changes to the database
1223         self.db.commit()
1225     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1226             nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1227         '''Figure the user's action and do it.
1228         '''
1229         # here be the "normal" functionality
1230         if action == 'index':
1231             self.index()
1232             return
1233         if action == 'list_classes':
1234             self.classes()
1235             return
1236         if action == 'classhelp':
1237             self.classhelp()
1238             return
1239         if action == 'login':
1240             self.login()
1241             return
1242         if action == 'logout':
1243             self.logout()
1244             return
1246         # see if we're to display an existing node
1247         m = dre.match(action)
1248         if m:
1249             self.classname = m.group(1)
1250             self.nodeid = m.group(2)
1251             try:
1252                 cl = self.db.classes[self.classname]
1253             except KeyError:
1254                 raise NotFound, self.classname
1255             try:
1256                 cl.get(self.nodeid, 'id')
1257             except IndexError:
1258                 raise NotFound, self.nodeid
1259             try:
1260                 func = getattr(self, 'show%s'%self.classname)
1261             except AttributeError:
1262                 raise NotFound, 'show%s'%self.classname
1263             func()
1264             return
1266         # see if we're to put up the new node page
1267         m = nre.match(action)
1268         if m:
1269             self.classname = m.group(1)
1270             try:
1271                 func = getattr(self, 'new%s'%self.classname)
1272             except AttributeError:
1273                 raise NotFound, 'new%s'%self.classname
1274             func()
1275             return
1277         # see if we're to put up the new node page
1278         m = sre.match(action)
1279         if m:
1280             self.classname = m.group(1)
1281             try:
1282                 func = getattr(self, 'search%s'%self.classname)
1283             except AttributeError:
1284                 raise NotFound
1285             func()
1286             return
1288         # otherwise, display the named class
1289         self.classname = action
1290         try:
1291             self.db.getclass(self.classname)
1292         except KeyError:
1293             raise NotFound, self.classname
1294         self.list()
1297 class ExtendedClient(Client): 
1298     '''Includes pages and page heading information that relate to the
1299        extended schema.
1300     ''' 
1301     showsupport = Client.shownode
1302     showtimelog = Client.shownode
1303     newsupport = Client.newnode
1304     newtimelog = Client.newnode
1305     searchsupport = Client.searchnode
1307     default_index_sort = ['-activity']
1308     default_index_group = ['priority']
1309     default_index_filter = ['status']
1310     default_index_columns = ['activity','status','title','assignedto']
1311     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1313 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1314     '''Pull properties for the given class out of the form.
1315     '''
1316     props = {}
1317     keys = form.keys()
1318     for key in keys:
1319         if not cl.properties.has_key(key):
1320             continue
1321         proptype = cl.properties[key]
1322         if isinstance(proptype, hyperdb.String):
1323             value = form[key].value.strip()
1324         elif isinstance(proptype, hyperdb.Password):
1325             value = password.Password(form[key].value.strip())
1326         elif isinstance(proptype, hyperdb.Date):
1327             value = form[key].value.strip()
1328             if value:
1329                 value = date.Date(form[key].value.strip())
1330             else:
1331                 value = None
1332         elif isinstance(proptype, hyperdb.Interval):
1333             value = form[key].value.strip()
1334             if value:
1335                 value = date.Interval(form[key].value.strip())
1336             else:
1337                 value = None
1338         elif isinstance(proptype, hyperdb.Link):
1339             value = form[key].value.strip()
1340             # see if it's the "no selection" choice
1341             if value == '-1':
1342                 # don't set this property
1343                 continue
1344             else:
1345                 # handle key values
1346                 link = cl.properties[key].classname
1347                 if not num_re.match(value):
1348                     try:
1349                         value = db.classes[link].lookup(value)
1350                     except KeyError:
1351                         raise ValueError, _('property "%(propname)s": '
1352                             '%(value)s not a %(classname)s')%{'propname':key, 
1353                             'value': value, 'classname': link}
1354         elif isinstance(proptype, hyperdb.Multilink):
1355             value = form[key]
1356             if type(value) != type([]):
1357                 value = [i.strip() for i in value.value.split(',')]
1358             else:
1359                 value = [i.value.strip() for i in value]
1360             link = cl.properties[key].classname
1361             l = []
1362             for entry in map(str, value):
1363                 if entry == '': continue
1364                 if not num_re.match(entry):
1365                     try:
1366                         entry = db.classes[link].lookup(entry)
1367                     except KeyError:
1368                         raise ValueError, _('property "%(propname)s": '
1369                             '"%(value)s" not an entry of %(classname)s')%{
1370                             'propname':key, 'value': entry, 'classname': link}
1371                 l.append(entry)
1372             l.sort()
1373             value = l
1375         # get the old value
1376         if nodeid:
1377             try:
1378                 existing = cl.get(nodeid, key)
1379             except KeyError:
1380                 # this might be a new property for which there is no existing
1381                 # value
1382                 if not cl.properties.has_key(key): raise
1384             # if changed, set it
1385             if value != existing:
1386                 props[key] = value
1387         else:
1388             props[key] = value
1389     return props
1392 # $Log: not supported by cvs2svn $
1393 # Revision 1.130  2002/06/27 12:01:53  gmcm
1394 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1395 # Some minor optimizations (only compile regexes once).
1397 # Revision 1.129  2002/06/20 23:52:11  richard
1398 # Better handling of unauth attempt to edit stuff
1400 # Revision 1.128  2002/06/12 21:28:25  gmcm
1401 # Allow form to set user-properties on a Fileclass.
1402 # Don't assume that a Fileclass is named "files".
1404 # Revision 1.127  2002/06/11 06:38:24  richard
1405 #  . #565996 ] The "Attach a File to this Issue" fails
1407 # Revision 1.126  2002/05/29 01:16:17  richard
1408 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1409 # though.
1411 # . #541941 ] changing multilink properties by mail
1412 # . #526730 ] search for messages capability
1413 # . #505180 ] split MailGW.handle_Message
1414 #   - also changed cgi client since it was duplicating the functionality
1415 # . build htmlbase if tests are run using CVS checkout (removed note from
1416 #   installation.txt)
1417 # . don't create an empty message on email issue creation if the email is empty
1419 # Revision 1.125  2002/05/25 07:16:24  rochecompaan
1420 # Merged search_indexing-branch with HEAD
1422 # Revision 1.124  2002/05/24 02:09:24  richard
1423 # Nothing like a live demo to show up the bugs ;)
1425 # Revision 1.123  2002/05/22 05:04:13  richard
1426 # Oops
1428 # Revision 1.122  2002/05/22 04:12:05  richard
1429 #  . applied patch #558876 ] cgi client customization
1430 #    ... with significant additions and modifications ;)
1431 #    - extended handling of ML assignedto to all places it's handled
1432 #    - added more NotFound info
1434 # Revision 1.121  2002/05/21 06:08:10  richard
1435 # Handle migration
1437 # Revision 1.120  2002/05/21 06:05:53  richard
1438 #  . #551483 ] assignedto in Client.make_index_link
1440 # Revision 1.119  2002/05/15 06:21:21  richard
1441 #  . node caching now works, and gives a small boost in performance
1443 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1444 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1445 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1446 # (using if __debug__ which is compiled out with -O)
1448 # Revision 1.118  2002/05/12 23:46:33  richard
1449 # ehem, part 2
1451 # Revision 1.117  2002/05/12 23:42:29  richard
1452 # ehem
1454 # Revision 1.116  2002/05/02 08:07:49  richard
1455 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1457 # Revision 1.115  2002/04/02 01:56:10  richard
1458 #  . stop sending blank (whitespace-only) notes
1460 # Revision 1.114.2.4  2002/05/02 11:49:18  rochecompaan
1461 # Allow customization of the search filters that should be displayed
1462 # on the search page.
1464 # Revision 1.114.2.3  2002/04/20 13:23:31  rochecompaan
1465 # We now have a separate search page for nodes.  Search links for
1466 # different classes can be customized in instance_config similar to
1467 # index links.
1469 # Revision 1.114.2.2  2002/04/19 19:54:42  rochecompaan
1470 # cgi_client.py
1471 #     removed search link for the time being
1472 #     moved rendering of matches to htmltemplate
1473 # hyperdb.py
1474 #     filtering of nodes on full text search incorporated in filter method
1475 # roundupdb.py
1476 #     added paramater to call of filter method
1477 # roundup_indexer.py
1478 #     added search method to RoundupIndexer class
1480 # Revision 1.114.2.1  2002/04/03 11:55:57  rochecompaan
1481 #  . Added feature #526730 - search for messages capability
1483 # Revision 1.114  2002/03/17 23:06:05  richard
1484 # oops
1486 # Revision 1.113  2002/03/14 23:59:24  richard
1487 #  . #517734 ] web header customisation is obscure
1489 # Revision 1.112  2002/03/12 22:52:26  richard
1490 # more pychecker warnings removed
1492 # Revision 1.111  2002/02/25 04:32:21  richard
1493 # ahem
1495 # Revision 1.110  2002/02/21 07:19:08  richard
1496 # ... and label, width and height control for extra flavour!
1498 # Revision 1.109  2002/02/21 07:08:19  richard
1499 # oops
1501 # Revision 1.108  2002/02/21 07:02:54  richard
1502 # The correct var is "HTTP_HOST"
1504 # Revision 1.107  2002/02/21 06:57:38  richard
1505 #  . Added popup help for classes using the classhelp html template function.
1506 #    - add <display call="classhelp('priority', 'id,name,description')">
1507 #      to an item page, and it generates a link to a popup window which displays
1508 #      the id, name and description for the priority class. The description
1509 #      field won't exist in most installations, but it will be added to the
1510 #      default templates.
1512 # Revision 1.106  2002/02/21 06:23:00  richard
1513 # *** empty log message ***
1515 # Revision 1.105  2002/02/20 05:52:10  richard
1516 # better error handling
1518 # Revision 1.104  2002/02/20 05:45:17  richard
1519 # Use the csv module for generating the form entry so it's correct.
1520 # [also noted the sf.net feature request id in the change log]
1522 # Revision 1.103  2002/02/20 05:05:28  richard
1523 #  . Added simple editing for classes that don't define a templated interface.
1524 #    - access using the admin "class list" interface
1525 #    - limited to admin-only
1526 #    - requires the csv module from object-craft (url given if it's missing)
1528 # Revision 1.102  2002/02/15 07:08:44  richard
1529 #  . Alternate email addresses are now available for users. See the MIGRATION
1530 #    file for info on how to activate the feature.
1532 # Revision 1.101  2002/02/14 23:39:18  richard
1533 # . All forms now have "double-submit" protection when Javascript is enabled
1534 #   on the client-side.
1536 # Revision 1.100  2002/01/16 07:02:57  richard
1537 #  . lots of date/interval related changes:
1538 #    - more relaxed date format for input
1540 # Revision 1.99  2002/01/16 03:02:42  richard
1541 # #503793 ] changing assignedto resets nosy list
1543 # Revision 1.98  2002/01/14 02:20:14  richard
1544 #  . changed all config accesses so they access either the instance or the
1545 #    config attriubute on the db. This means that all config is obtained from
1546 #    instance_config instead of the mish-mash of classes. This will make
1547 #    switching to a ConfigParser setup easier too, I hope.
1549 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1550 # 0.5.0 switch, I hope!)
1552 # Revision 1.97  2002/01/11 23:22:29  richard
1553 #  . #502437 ] rogue reactor and unittest
1554 #    in short, the nosy reactor was modifying the nosy list. That code had
1555 #    been there for a long time, and I suspsect it was there because we
1556 #    weren't generating the nosy list correctly in other places of the code.
1557 #    We're now doing that, so the nosy-modifying code can go away from the
1558 #    nosy reactor.
1560 # Revision 1.96  2002/01/10 05:26:10  richard
1561 # missed a parsePropsFromForm in last update
1563 # Revision 1.95  2002/01/10 03:39:45  richard
1564 #  . fixed some problems with web editing and change detection
1566 # Revision 1.94  2002/01/09 13:54:21  grubert
1567 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1569 # Revision 1.93  2002/01/08 11:57:12  richard
1570 # crying out for real configuration handling... :(
1572 # Revision 1.92  2002/01/08 04:12:05  richard
1573 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1575 # Revision 1.91  2002/01/08 04:03:47  richard
1576 # I mucked the intent of the code up.
1578 # Revision 1.90  2002/01/08 03:56:55  richard
1579 # Oops, missed this before the beta:
1580 #  . #495392 ] empty nosy -patch
1582 # Revision 1.89  2002/01/07 20:24:45  richard
1583 # *mutter* stupid cutnpaste
1585 # Revision 1.88  2002/01/02 02:31:38  richard
1586 # Sorry for the huge checkin message - I was only intending to implement #496356
1587 # but I found a number of places where things had been broken by transactions:
1588 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1589 #    for _all_ roundup-generated smtp messages to be sent to.
1590 #  . the transaction cache had broken the roundupdb.Class set() reactors
1591 #  . newly-created author users in the mailgw weren't being committed to the db
1593 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1594 # on when I found that stuff :):
1595 #  . #496356 ] Use threading in messages
1596 #  . detectors were being registered multiple times
1597 #  . added tests for mailgw
1598 #  . much better attaching of erroneous messages in the mail gateway
1600 # Revision 1.87  2001/12/23 23:18:49  richard
1601 # We already had an admin-specific section of the web heading, no need to add
1602 # another one :)
1604 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1605 # Features added:
1606 #  .  Multilink properties are now displayed as comma separated values in
1607 #     a textbox
1608 #  .  The add user link is now only visible to the admin user
1609 #  .  Modified the mail gateway to reject submissions from unknown
1610 #     addresses if ANONYMOUS_ACCESS is denied
1612 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1613 # Bugs fixed:
1614 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1615 #     lost somewhere
1616 #   . Internet Explorer submits full path for filename - we now strip away
1617 #     the path
1618 # Features added:
1619 #   . Link and multilink properties are now displayed sorted in the cgi
1620 #     interface
1622 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1623 # Fixed bugs:
1624 #  .  Fixed file creation and retrieval in same transaction in anydbm
1625 #     backend
1626 #  .  Cgi interface now renders new issue after issue creation
1627 #  .  Could not set issue status to resolved through cgi interface
1628 #  .  Mail gateway was changing status back to 'chatting' if status was
1629 #     omitted as an argument
1631 # Revision 1.83  2001/12/15 23:51:01  richard
1632 # Tested the changes and fixed a few problems:
1633 #  . files are now attached to the issue as well as the message
1634 #  . newuser is a real method now since we don't want to do the message/file
1635 #    stuff for it
1636 #  . added some documentation
1637 # The really big changes in the diff are a result of me moving some code
1638 # around to keep like methods together a bit better.
1640 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1641 #  . Modified cgi interface to change properties only once all changes are
1642 #    collected, files created and messages generated.
1643 #  . Moved generation of change note to nosyreactors.
1644 #  . We now check for changes to "assignedto" to ensure it's added to the
1645 #    nosy list.
1647 # Revision 1.81  2001/12/12 23:55:00  richard
1648 # Fixed some problems with user editing
1650 # Revision 1.80  2001/12/12 23:27:14  richard
1651 # Added a Zope frontend for roundup.
1653 # Revision 1.79  2001/12/10 22:20:01  richard
1654 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1655 # where possible, only replacing methods where the db is opened (it uses the
1656 # btree opener specifically.)
1657 # Also cleaned up some change note generation.
1658 # Made the backends package work with pydoc too.
1660 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1661 # Fixed small bug that prevented adding issues through the web.
1663 # Revision 1.77  2001/12/06 22:48:29  richard
1664 # files multilink was being nuked in post_edit_node
1666 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1667 # Removed generation of change note from "sendmessage" in roundupdb.py.
1668 # The change note is now generated when the message is created.
1670 # Revision 1.75  2001/12/04 01:25:08  richard
1671 # Added some rollbacks where we were catching exceptions that would otherwise
1672 # have stopped committing.
1674 # Revision 1.74  2001/12/02 05:06:16  richard
1675 # . We now use weakrefs in the Classes to keep the database reference, so
1676 #   the close() method on the database is no longer needed.
1677 #   I bumped the minimum python requirement up to 2.1 accordingly.
1678 # . #487480 ] roundup-server
1679 # . #487476 ] INSTALL.txt
1681 # I also cleaned up the change message / post-edit stuff in the cgi client.
1682 # There's now a clearly marked "TODO: append the change note" where I believe
1683 # the change note should be added there. The "changes" list will obviously
1684 # have to be modified to be a dict of the changes, or somesuch.
1686 # More testing needed.
1688 # Revision 1.73  2001/12/01 07:17:50  richard
1689 # . We now have basic transaction support! Information is only written to
1690 #   the database when the commit() method is called. Only the anydbm
1691 #   backend is modified in this way - neither of the bsddb backends have been.
1692 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1693 #   doesn't have a commit command, so interactive users can't commit...)
1694 # . Fixed login/registration forwarding the user to the right page (or not,
1695 #   on a failure)
1697 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1698 # Links in page header are now consistent with default sort order.
1700 # Fixed bugs:
1701 #     - When login failed the list of issues were still rendered.
1702 #     - User was redirected to index page and not to his destination url
1703 #       if his first login attempt failed.
1705 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1706 # Property changes are now completely traceable, whether changes are
1707 # made through the web or by email
1709 # Revision 1.70  2001/11/30 00:06:29  richard
1710 # Converted roundup/cgi_client.py to use _()
1711 # Added the status file, I18N_PROGRESS.txt
1713 # Revision 1.69  2001/11/29 23:19:51  richard
1714 # Removed the "This issue has been edited through the web" when a valid
1715 # change note is supplied.
1717 # Revision 1.68  2001/11/29 04:57:23  richard
1718 # a little comment
1720 # Revision 1.67  2001/11/28 21:55:35  richard
1721 #  . login_action and newuser_action return values were being ignored
1722 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1723 #    gateway.
1724 #  (also a minor cleanup in hyperdb)
1726 # Revision 1.66  2001/11/27 03:00:50  richard
1727 # couple of bugfixes from latest patch integration
1729 # Revision 1.65  2001/11/26 23:00:53  richard
1730 # This config stuff is getting to be a real mess...
1732 # Revision 1.64  2001/11/26 22:56:35  richard
1733 # typo
1735 # Revision 1.63  2001/11/26 22:55:56  richard
1736 # Feature:
1737 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1738 #    the instance.
1739 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1740 #    signature info in e-mails.
1741 #  . Some more flexibility in the mail gateway and more error handling.
1742 #  . Login now takes you to the page you back to the were denied access to.
1744 # Fixed:
1745 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1747 # Revision 1.62  2001/11/24 00:45:42  jhermann
1748 # typeof() instead of type(): avoid clash with database field(?) "type"
1750 # Fixes this traceback:
1752 # Traceback (most recent call last):
1753 #   File "roundup\cgi_client.py", line 535, in newnode
1754 #     self._post_editnode(nid)
1755 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1756 #     if type(value) != type([]): value = [value]
1757 # UnboundLocalError: local variable 'type' referenced before assignment
1759 # Revision 1.61  2001/11/22 15:46:42  jhermann
1760 # Added module docstrings to all modules.
1762 # Revision 1.60  2001/11/21 22:57:28  jhermann
1763 # Added dummy hooks for I18N and some preliminary (test) markup of
1764 # translatable messages
1766 # Revision 1.59  2001/11/21 03:21:13  richard
1767 # oops
1769 # Revision 1.58  2001/11/21 03:11:28  richard
1770 # Better handling of new properties.
1772 # Revision 1.57  2001/11/15 10:24:27  richard
1773 # handle the case where there is no file attached
1775 # Revision 1.56  2001/11/14 21:35:21  richard
1776 #  . users may attach files to issues (and support in ext) through the web now
1778 # Revision 1.55  2001/11/07 02:34:06  jhermann
1779 # Handling of damaged login cookies
1781 # Revision 1.54  2001/11/07 01:16:12  richard
1782 # Remove the '=' padding from cookie value so quoting isn't an issue.
1784 # Revision 1.53  2001/11/06 23:22:05  jhermann
1785 # More IE fixes: it does not like quotes around cookie values; in the
1786 # hope this does not break anything for other browser; if it does, we
1787 # need to check HTTP_USER_AGENT
1789 # Revision 1.52  2001/11/06 23:11:22  jhermann
1790 # Fixed debug output in page footer; added expiry date to the login cookie
1791 # (expires 1 year in the future) to prevent probs with certain versions
1792 # of IE
1794 # Revision 1.51  2001/11/06 22:00:34  jhermann
1795 # Get debug level from ROUNDUP_DEBUG env var
1797 # Revision 1.50  2001/11/05 23:45:40  richard
1798 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1799 # Also made it present nicer error messages (not tracebacks).
1801 # Revision 1.49  2001/11/04 03:07:12  richard
1802 # Fixed various cookie-related bugs:
1803 #  . bug #477685 ] base64.decodestring breaks
1804 #  . bug #477837 ] lynx does not like the cookie
1805 #  . bug #477892 ] Password edit doesn't fix login cookie
1806 # Also closed a security hole - a logged-in user could edit another user's
1807 # details.
1809 # Revision 1.48  2001/11/03 01:30:18  richard
1810 # Oops. uses pagefoot now.
1812 # Revision 1.47  2001/11/03 01:29:28  richard
1813 # Login page didn't have all close tags.
1815 # Revision 1.46  2001/11/03 01:26:55  richard
1816 # possibly fix truncated base64'ed user:pass
1818 # Revision 1.45  2001/11/01 22:04:37  richard
1819 # Started work on supporting a pop3-fetching server
1820 # Fixed bugs:
1821 #  . bug #477104 ] HTML tag error in roundup-server
1822 #  . bug #477107 ] HTTP header problem
1824 # Revision 1.44  2001/10/28 23:03:08  richard
1825 # Added more useful header to the classic schema.
1827 # Revision 1.43  2001/10/24 00:01:42  richard
1828 # More fixes to lockout logic.
1830 # Revision 1.42  2001/10/23 23:56:03  richard
1831 # HTML typo
1833 # Revision 1.41  2001/10/23 23:52:35  richard
1834 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1836 # Revision 1.40  2001/10/23 23:06:39  richard
1837 # Some cleanup.
1839 # Revision 1.39  2001/10/23 01:00:18  richard
1840 # Re-enabled login and registration access after lopping them off via
1841 # disabling access for anonymous users.
1842 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1843 # a couple of bugs while I was there. Probably introduced a couple, but
1844 # things seem to work OK at the moment.
1846 # Revision 1.38  2001/10/22 03:25:01  richard
1847 # Added configuration for:
1848 #  . anonymous user access and registration (deny/allow)
1849 #  . filter "widget" location on index page (top, bottom, both)
1850 # Updated some documentation.
1852 # Revision 1.37  2001/10/21 07:26:35  richard
1853 # feature #473127: Filenames. I modified the file.index and htmltemplate
1854 #  source so that the filename is used in the link and the creation
1855 #  information is displayed.
1857 # Revision 1.36  2001/10/21 04:44:50  richard
1858 # bug #473124: UI inconsistency with Link fields.
1859 #    This also prompted me to fix a fairly long-standing usability issue -
1860 #    that of being able to turn off certain filters.
1862 # Revision 1.35  2001/10/21 00:17:54  richard
1863 # CGI interface view customisation section may now be hidden (patch from
1864 #  Roch'e Compaan.)
1866 # Revision 1.34  2001/10/20 11:58:48  richard
1867 # Catch errors in login - no username or password supplied.
1868 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1870 # Revision 1.33  2001/10/17 00:18:41  richard
1871 # Manually constructing cookie headers now.
1873 # Revision 1.32  2001/10/16 03:36:21  richard
1874 # CGI interface wasn't handling checkboxes at all.
1876 # Revision 1.31  2001/10/14 10:55:00  richard
1877 # Handle empty strings in HTML template Link function
1879 # Revision 1.30  2001/10/09 07:38:58  richard
1880 # Pushed the base code for the extended schema CGI interface back into the
1881 # code cgi_client module so that future updates will be less painful.
1882 # Also removed a debugging print statement from cgi_client.
1884 # Revision 1.29  2001/10/09 07:25:59  richard
1885 # Added the Password property type. See "pydoc roundup.password" for
1886 # implementation details. Have updated some of the documentation too.
1888 # Revision 1.28  2001/10/08 00:34:31  richard
1889 # Change message was stuffing up for multilinks with no key property.
1891 # Revision 1.27  2001/10/05 02:23:24  richard
1892 #  . roundup-admin create now prompts for property info if none is supplied
1893 #    on the command-line.
1894 #  . hyperdb Class getprops() method may now return only the mutable
1895 #    properties.
1896 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1897 #    now support anonymous user access (read-only, unless there's an
1898 #    "anonymous" user, in which case write access is permitted). Login
1899 #    handling has been moved into cgi_client.Client.main()
1900 #  . The "extended" schema is now the default in roundup init.
1901 #  . The schemas have had their page headings modified to cope with the new
1902 #    login handling. Existing installations should copy the interfaces.py
1903 #    file from the roundup lib directory to their instance home.
1904 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1905 #    Ping - has been removed.
1906 #  . Fixed a whole bunch of places in the CGI interface where we should have
1907 #    been returning Not Found instead of throwing an exception.
1908 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1909 #    an item now throws an exception.
1911 # Revision 1.26  2001/09/12 08:31:42  richard
1912 # handle cases where mime type is not guessable
1914 # Revision 1.25  2001/08/29 05:30:49  richard
1915 # change messages weren't being saved when there was no-one on the nosy list.
1917 # Revision 1.24  2001/08/29 04:49:39  richard
1918 # didn't clean up fully after debugging :(
1920 # Revision 1.23  2001/08/29 04:47:18  richard
1921 # Fixed CGI client change messages so they actually include the properties
1922 # changed (again).
1924 # Revision 1.22  2001/08/17 00:08:10  richard
1925 # reverted back to sending messages always regardless of who is doing the web
1926 # edit. change notes weren't being saved. bleah. hackish.
1928 # Revision 1.21  2001/08/15 23:43:18  richard
1929 # Fixed some isFooTypes that I missed.
1930 # Refactored some code in the CGI code.
1932 # Revision 1.20  2001/08/12 06:32:36  richard
1933 # using isinstance(blah, Foo) now instead of isFooType
1935 # Revision 1.19  2001/08/07 00:24:42  richard
1936 # stupid typo
1938 # Revision 1.18  2001/08/07 00:15:51  richard
1939 # Added the copyright/license notice to (nearly) all files at request of
1940 # Bizar Software.
1942 # Revision 1.17  2001/08/02 06:38:17  richard
1943 # Roundupdb now appends "mailing list" information to its messages which
1944 # include the e-mail address and web interface address. Templates may
1945 # override this in their db classes to include specific information (support
1946 # instructions, etc).
1948 # Revision 1.16  2001/08/02 05:55:25  richard
1949 # Web edit messages aren't sent to the person who did the edit any more. No
1950 # message is generated if they are the only person on the nosy list.
1952 # Revision 1.15  2001/08/02 00:34:10  richard
1953 # bleah syntax error
1955 # Revision 1.14  2001/08/02 00:26:16  richard
1956 # Changed the order of the information in the message generated by web edits.
1958 # Revision 1.13  2001/07/30 08:12:17  richard
1959 # Added time logging and file uploading to the templates.
1961 # Revision 1.12  2001/07/30 06:26:31  richard
1962 # Added some documentation on how the newblah works.
1964 # Revision 1.11  2001/07/30 06:17:45  richard
1965 # Features:
1966 #  . Added ability for cgi newblah forms to indicate that the new node
1967 #    should be linked somewhere.
1968 # Fixed:
1969 #  . Fixed the agument handling for the roundup-admin find command.
1970 #  . Fixed handling of summary when no note supplied for newblah. Again.
1971 #  . Fixed detection of no form in htmltemplate Field display.
1973 # Revision 1.10  2001/07/30 02:37:34  richard
1974 # Temporary measure until we have decent schema migration...
1976 # Revision 1.9  2001/07/30 01:25:07  richard
1977 # Default implementation is now "classic" rather than "extended" as one would
1978 # expect.
1980 # Revision 1.8  2001/07/29 08:27:40  richard
1981 # Fixed handling of passed-in values in form elements (ie. during a
1982 # drill-down)
1984 # Revision 1.7  2001/07/29 07:01:39  richard
1985 # Added vim command to all source so that we don't get no steenkin' tabs :)
1987 # Revision 1.6  2001/07/29 04:04:00  richard
1988 # Moved some code around allowing for subclassing to change behaviour.
1990 # Revision 1.5  2001/07/28 08:16:52  richard
1991 # New issue form handles lack of note better now.
1993 # Revision 1.4  2001/07/28 00:34:34  richard
1994 # Fixed some non-string node ids.
1996 # Revision 1.3  2001/07/23 03:56:30  richard
1997 # oops, missed a config removal
1999 # Revision 1.2  2001/07/22 12:09:32  richard
2000 # Final commit of Grande Splite
2002 # Revision 1.1  2001/07/22 11:58:35  richard
2003 # More Grande Splite
2006 # vim: set filetype=python ts=4 sw=4 et si