Code

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