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;
112 }
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);
116 }
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 == '+'
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']
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)
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.
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.
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
1391 #
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).
1396 #
1397 # Revision 1.129 2002/06/20 23:52:11 richard
1398 # Better handling of unauth attempt to edit stuff
1399 #
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".
1403 #
1404 # Revision 1.127 2002/06/11 06:38:24 richard
1405 # . #565996 ] The "Attach a File to this Issue" fails
1406 #
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.
1410 #
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
1418 #
1419 # Revision 1.125 2002/05/25 07:16:24 rochecompaan
1420 # Merged search_indexing-branch with HEAD
1421 #
1422 # Revision 1.124 2002/05/24 02:09:24 richard
1423 # Nothing like a live demo to show up the bugs ;)
1424 #
1425 # Revision 1.123 2002/05/22 05:04:13 richard
1426 # Oops
1427 #
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
1433 #
1434 # Revision 1.121 2002/05/21 06:08:10 richard
1435 # Handle migration
1436 #
1437 # Revision 1.120 2002/05/21 06:05:53 richard
1438 # . #551483 ] assignedto in Client.make_index_link
1439 #
1440 # Revision 1.119 2002/05/15 06:21:21 richard
1441 # . node caching now works, and gives a small boost in performance
1442 #
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)
1447 #
1448 # Revision 1.118 2002/05/12 23:46:33 richard
1449 # ehem, part 2
1450 #
1451 # Revision 1.117 2002/05/12 23:42:29 richard
1452 # ehem
1453 #
1454 # Revision 1.116 2002/05/02 08:07:49 richard
1455 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1456 #
1457 # Revision 1.115 2002/04/02 01:56:10 richard
1458 # . stop sending blank (whitespace-only) notes
1459 #
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.
1463 #
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.
1468 #
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
1479 #
1480 # Revision 1.114.2.1 2002/04/03 11:55:57 rochecompaan
1481 # . Added feature #526730 - search for messages capability
1482 #
1483 # Revision 1.114 2002/03/17 23:06:05 richard
1484 # oops
1485 #
1486 # Revision 1.113 2002/03/14 23:59:24 richard
1487 # . #517734 ] web header customisation is obscure
1488 #
1489 # Revision 1.112 2002/03/12 22:52:26 richard
1490 # more pychecker warnings removed
1491 #
1492 # Revision 1.111 2002/02/25 04:32:21 richard
1493 # ahem
1494 #
1495 # Revision 1.110 2002/02/21 07:19:08 richard
1496 # ... and label, width and height control for extra flavour!
1497 #
1498 # Revision 1.109 2002/02/21 07:08:19 richard
1499 # oops
1500 #
1501 # Revision 1.108 2002/02/21 07:02:54 richard
1502 # The correct var is "HTTP_HOST"
1503 #
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.
1511 #
1512 # Revision 1.106 2002/02/21 06:23:00 richard
1513 # *** empty log message ***
1514 #
1515 # Revision 1.105 2002/02/20 05:52:10 richard
1516 # better error handling
1517 #
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]
1521 #
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)
1527 #
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.
1531 #
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.
1535 #
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
1539 #
1540 # Revision 1.99 2002/01/16 03:02:42 richard
1541 # #503793 ] changing assignedto resets nosy list
1542 #
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.
1548 #
1549 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1550 # 0.5.0 switch, I hope!)
1551 #
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.
1559 #
1560 # Revision 1.96 2002/01/10 05:26:10 richard
1561 # missed a parsePropsFromForm in last update
1562 #
1563 # Revision 1.95 2002/01/10 03:39:45 richard
1564 # . fixed some problems with web editing and change detection
1565 #
1566 # Revision 1.94 2002/01/09 13:54:21 grubert
1567 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1568 #
1569 # Revision 1.93 2002/01/08 11:57:12 richard
1570 # crying out for real configuration handling... :(
1571 #
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
1574 #
1575 # Revision 1.91 2002/01/08 04:03:47 richard
1576 # I mucked the intent of the code up.
1577 #
1578 # Revision 1.90 2002/01/08 03:56:55 richard
1579 # Oops, missed this before the beta:
1580 # . #495392 ] empty nosy -patch
1581 #
1582 # Revision 1.89 2002/01/07 20:24:45 richard
1583 # *mutter* stupid cutnpaste
1584 #
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
1592 #
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
1599 #
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 :)
1603 #
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
1611 #
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
1621 #
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
1630 #
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.
1639 #
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.
1646 #
1647 # Revision 1.81 2001/12/12 23:55:00 richard
1648 # Fixed some problems with user editing
1649 #
1650 # Revision 1.80 2001/12/12 23:27:14 richard
1651 # Added a Zope frontend for roundup.
1652 #
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.
1659 #
1660 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1661 # Fixed small bug that prevented adding issues through the web.
1662 #
1663 # Revision 1.77 2001/12/06 22:48:29 richard
1664 # files multilink was being nuked in post_edit_node
1665 #
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.
1669 #
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.
1673 #
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
1680 #
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.
1685 #
1686 # More testing needed.
1687 #
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)
1696 #
1697 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1698 # Links in page header are now consistent with default sort order.
1699 #
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.
1704 #
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
1708 #
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
1712 #
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.
1716 #
1717 # Revision 1.68 2001/11/29 04:57:23 richard
1718 # a little comment
1719 #
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)
1725 #
1726 # Revision 1.66 2001/11/27 03:00:50 richard
1727 # couple of bugfixes from latest patch integration
1728 #
1729 # Revision 1.65 2001/11/26 23:00:53 richard
1730 # This config stuff is getting to be a real mess...
1731 #
1732 # Revision 1.64 2001/11/26 22:56:35 richard
1733 # typo
1734 #
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.
1743 #
1744 # Fixed:
1745 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1746 #
1747 # Revision 1.62 2001/11/24 00:45:42 jhermann
1748 # typeof() instead of type(): avoid clash with database field(?) "type"
1749 #
1750 # Fixes this traceback:
1751 #
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
1758 #
1759 # Revision 1.61 2001/11/22 15:46:42 jhermann
1760 # Added module docstrings to all modules.
1761 #
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
1765 #
1766 # Revision 1.59 2001/11/21 03:21:13 richard
1767 # oops
1768 #
1769 # Revision 1.58 2001/11/21 03:11:28 richard
1770 # Better handling of new properties.
1771 #
1772 # Revision 1.57 2001/11/15 10:24:27 richard
1773 # handle the case where there is no file attached
1774 #
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
1777 #
1778 # Revision 1.55 2001/11/07 02:34:06 jhermann
1779 # Handling of damaged login cookies
1780 #
1781 # Revision 1.54 2001/11/07 01:16:12 richard
1782 # Remove the '=' padding from cookie value so quoting isn't an issue.
1783 #
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
1788 #
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
1793 #
1794 # Revision 1.51 2001/11/06 22:00:34 jhermann
1795 # Get debug level from ROUNDUP_DEBUG env var
1796 #
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).
1800 #
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.
1808 #
1809 # Revision 1.48 2001/11/03 01:30:18 richard
1810 # Oops. uses pagefoot now.
1811 #
1812 # Revision 1.47 2001/11/03 01:29:28 richard
1813 # Login page didn't have all close tags.
1814 #
1815 # Revision 1.46 2001/11/03 01:26:55 richard
1816 # possibly fix truncated base64'ed user:pass
1817 #
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
1823 #
1824 # Revision 1.44 2001/10/28 23:03:08 richard
1825 # Added more useful header to the classic schema.
1826 #
1827 # Revision 1.43 2001/10/24 00:01:42 richard
1828 # More fixes to lockout logic.
1829 #
1830 # Revision 1.42 2001/10/23 23:56:03 richard
1831 # HTML typo
1832 #
1833 # Revision 1.41 2001/10/23 23:52:35 richard
1834 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1835 #
1836 # Revision 1.40 2001/10/23 23:06:39 richard
1837 # Some cleanup.
1838 #
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.
1845 #
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.
1851 #
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.
1856 #
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.
1861 #
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.)
1865 #
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.
1869 #
1870 # Revision 1.33 2001/10/17 00:18:41 richard
1871 # Manually constructing cookie headers now.
1872 #
1873 # Revision 1.32 2001/10/16 03:36:21 richard
1874 # CGI interface wasn't handling checkboxes at all.
1875 #
1876 # Revision 1.31 2001/10/14 10:55:00 richard
1877 # Handle empty strings in HTML template Link function
1878 #
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.
1883 #
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.
1887 #
1888 # Revision 1.28 2001/10/08 00:34:31 richard
1889 # Change message was stuffing up for multilinks with no key property.
1890 #
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.
1910 #
1911 # Revision 1.26 2001/09/12 08:31:42 richard
1912 # handle cases where mime type is not guessable
1913 #
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.
1916 #
1917 # Revision 1.24 2001/08/29 04:49:39 richard
1918 # didn't clean up fully after debugging :(
1919 #
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).
1923 #
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.
1927 #
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.
1931 #
1932 # Revision 1.20 2001/08/12 06:32:36 richard
1933 # using isinstance(blah, Foo) now instead of isFooType
1934 #
1935 # Revision 1.19 2001/08/07 00:24:42 richard
1936 # stupid typo
1937 #
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.
1941 #
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).
1947 #
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.
1951 #
1952 # Revision 1.15 2001/08/02 00:34:10 richard
1953 # bleah syntax error
1954 #
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.
1957 #
1958 # Revision 1.13 2001/07/30 08:12:17 richard
1959 # Added time logging and file uploading to the templates.
1960 #
1961 # Revision 1.12 2001/07/30 06:26:31 richard
1962 # Added some documentation on how the newblah works.
1963 #
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.
1972 #
1973 # Revision 1.10 2001/07/30 02:37:34 richard
1974 # Temporary measure until we have decent schema migration...
1975 #
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.
1979 #
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)
1983 #
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 :)
1986 #
1987 # Revision 1.6 2001/07/29 04:04:00 richard
1988 # Moved some code around allowing for subclassing to change behaviour.
1989 #
1990 # Revision 1.5 2001/07/28 08:16:52 richard
1991 # New issue form handles lack of note better now.
1992 #
1993 # Revision 1.4 2001/07/28 00:34:34 richard
1994 # Fixed some non-string node ids.
1995 #
1996 # Revision 1.3 2001/07/23 03:56:30 richard
1997 # oops, missed a config removal
1998 #
1999 # Revision 1.2 2001/07/22 12:09:32 richard
2000 # Final commit of Grande Splite
2001 #
2002 # Revision 1.1 2001/07/22 11:58:35 richard
2003 # More Grande Splite
2004 #
2005 #
2006 # vim: set filetype=python ts=4 sw=4 et si