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;
114 }
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);
118 }
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 == '+'
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']
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)
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.
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.
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
1393 #
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
1397 #
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".
1401 #
1402 # Revision 1.127 2002/06/11 06:38:24 richard
1403 # . #565996 ] The "Attach a File to this Issue" fails
1404 #
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.
1408 #
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
1416 #
1417 # Revision 1.125 2002/05/25 07:16:24 rochecompaan
1418 # Merged search_indexing-branch with HEAD
1419 #
1420 # Revision 1.124 2002/05/24 02:09:24 richard
1421 # Nothing like a live demo to show up the bugs ;)
1422 #
1423 # Revision 1.123 2002/05/22 05:04:13 richard
1424 # Oops
1425 #
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
1431 #
1432 # Revision 1.121 2002/05/21 06:08:10 richard
1433 # Handle migration
1434 #
1435 # Revision 1.120 2002/05/21 06:05:53 richard
1436 # . #551483 ] assignedto in Client.make_index_link
1437 #
1438 # Revision 1.119 2002/05/15 06:21:21 richard
1439 # . node caching now works, and gives a small boost in performance
1440 #
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)
1445 #
1446 # Revision 1.118 2002/05/12 23:46:33 richard
1447 # ehem, part 2
1448 #
1449 # Revision 1.117 2002/05/12 23:42:29 richard
1450 # ehem
1451 #
1452 # Revision 1.116 2002/05/02 08:07:49 richard
1453 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1454 #
1455 # Revision 1.115 2002/04/02 01:56:10 richard
1456 # . stop sending blank (whitespace-only) notes
1457 #
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.
1461 #
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.
1466 #
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
1477 #
1478 # Revision 1.114.2.1 2002/04/03 11:55:57 rochecompaan
1479 # . Added feature #526730 - search for messages capability
1480 #
1481 # Revision 1.114 2002/03/17 23:06:05 richard
1482 # oops
1483 #
1484 # Revision 1.113 2002/03/14 23:59:24 richard
1485 # . #517734 ] web header customisation is obscure
1486 #
1487 # Revision 1.112 2002/03/12 22:52:26 richard
1488 # more pychecker warnings removed
1489 #
1490 # Revision 1.111 2002/02/25 04:32:21 richard
1491 # ahem
1492 #
1493 # Revision 1.110 2002/02/21 07:19:08 richard
1494 # ... and label, width and height control for extra flavour!
1495 #
1496 # Revision 1.109 2002/02/21 07:08:19 richard
1497 # oops
1498 #
1499 # Revision 1.108 2002/02/21 07:02:54 richard
1500 # The correct var is "HTTP_HOST"
1501 #
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.
1509 #
1510 # Revision 1.106 2002/02/21 06:23:00 richard
1511 # *** empty log message ***
1512 #
1513 # Revision 1.105 2002/02/20 05:52:10 richard
1514 # better error handling
1515 #
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]
1519 #
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)
1525 #
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.
1529 #
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.
1533 #
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
1537 #
1538 # Revision 1.99 2002/01/16 03:02:42 richard
1539 # #503793 ] changing assignedto resets nosy list
1540 #
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.
1546 #
1547 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1548 # 0.5.0 switch, I hope!)
1549 #
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.
1557 #
1558 # Revision 1.96 2002/01/10 05:26:10 richard
1559 # missed a parsePropsFromForm in last update
1560 #
1561 # Revision 1.95 2002/01/10 03:39:45 richard
1562 # . fixed some problems with web editing and change detection
1563 #
1564 # Revision 1.94 2002/01/09 13:54:21 grubert
1565 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1566 #
1567 # Revision 1.93 2002/01/08 11:57:12 richard
1568 # crying out for real configuration handling... :(
1569 #
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
1572 #
1573 # Revision 1.91 2002/01/08 04:03:47 richard
1574 # I mucked the intent of the code up.
1575 #
1576 # Revision 1.90 2002/01/08 03:56:55 richard
1577 # Oops, missed this before the beta:
1578 # . #495392 ] empty nosy -patch
1579 #
1580 # Revision 1.89 2002/01/07 20:24:45 richard
1581 # *mutter* stupid cutnpaste
1582 #
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
1590 #
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
1597 #
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 :)
1601 #
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
1609 #
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
1619 #
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
1628 #
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.
1637 #
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.
1644 #
1645 # Revision 1.81 2001/12/12 23:55:00 richard
1646 # Fixed some problems with user editing
1647 #
1648 # Revision 1.80 2001/12/12 23:27:14 richard
1649 # Added a Zope frontend for roundup.
1650 #
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.
1657 #
1658 # Revision 1.78 2001/12/07 05:59:27 rochecompaan
1659 # Fixed small bug that prevented adding issues through the web.
1660 #
1661 # Revision 1.77 2001/12/06 22:48:29 richard
1662 # files multilink was being nuked in post_edit_node
1663 #
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.
1667 #
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.
1671 #
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
1678 #
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.
1683 #
1684 # More testing needed.
1685 #
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)
1694 #
1695 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1696 # Links in page header are now consistent with default sort order.
1697 #
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.
1702 #
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
1706 #
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
1710 #
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.
1714 #
1715 # Revision 1.68 2001/11/29 04:57:23 richard
1716 # a little comment
1717 #
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)
1723 #
1724 # Revision 1.66 2001/11/27 03:00:50 richard
1725 # couple of bugfixes from latest patch integration
1726 #
1727 # Revision 1.65 2001/11/26 23:00:53 richard
1728 # This config stuff is getting to be a real mess...
1729 #
1730 # Revision 1.64 2001/11/26 22:56:35 richard
1731 # typo
1732 #
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.
1741 #
1742 # Fixed:
1743 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1744 #
1745 # Revision 1.62 2001/11/24 00:45:42 jhermann
1746 # typeof() instead of type(): avoid clash with database field(?) "type"
1747 #
1748 # Fixes this traceback:
1749 #
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
1756 #
1757 # Revision 1.61 2001/11/22 15:46:42 jhermann
1758 # Added module docstrings to all modules.
1759 #
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
1763 #
1764 # Revision 1.59 2001/11/21 03:21:13 richard
1765 # oops
1766 #
1767 # Revision 1.58 2001/11/21 03:11:28 richard
1768 # Better handling of new properties.
1769 #
1770 # Revision 1.57 2001/11/15 10:24:27 richard
1771 # handle the case where there is no file attached
1772 #
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
1775 #
1776 # Revision 1.55 2001/11/07 02:34:06 jhermann
1777 # Handling of damaged login cookies
1778 #
1779 # Revision 1.54 2001/11/07 01:16:12 richard
1780 # Remove the '=' padding from cookie value so quoting isn't an issue.
1781 #
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
1786 #
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
1791 #
1792 # Revision 1.51 2001/11/06 22:00:34 jhermann
1793 # Get debug level from ROUNDUP_DEBUG env var
1794 #
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).
1798 #
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.
1806 #
1807 # Revision 1.48 2001/11/03 01:30:18 richard
1808 # Oops. uses pagefoot now.
1809 #
1810 # Revision 1.47 2001/11/03 01:29:28 richard
1811 # Login page didn't have all close tags.
1812 #
1813 # Revision 1.46 2001/11/03 01:26:55 richard
1814 # possibly fix truncated base64'ed user:pass
1815 #
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
1821 #
1822 # Revision 1.44 2001/10/28 23:03:08 richard
1823 # Added more useful header to the classic schema.
1824 #
1825 # Revision 1.43 2001/10/24 00:01:42 richard
1826 # More fixes to lockout logic.
1827 #
1828 # Revision 1.42 2001/10/23 23:56:03 richard
1829 # HTML typo
1830 #
1831 # Revision 1.41 2001/10/23 23:52:35 richard
1832 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1833 #
1834 # Revision 1.40 2001/10/23 23:06:39 richard
1835 # Some cleanup.
1836 #
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.
1843 #
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.
1849 #
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.
1854 #
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.
1859 #
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.)
1863 #
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.
1867 #
1868 # Revision 1.33 2001/10/17 00:18:41 richard
1869 # Manually constructing cookie headers now.
1870 #
1871 # Revision 1.32 2001/10/16 03:36:21 richard
1872 # CGI interface wasn't handling checkboxes at all.
1873 #
1874 # Revision 1.31 2001/10/14 10:55:00 richard
1875 # Handle empty strings in HTML template Link function
1876 #
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.
1881 #
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.
1885 #
1886 # Revision 1.28 2001/10/08 00:34:31 richard
1887 # Change message was stuffing up for multilinks with no key property.
1888 #
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.
1908 #
1909 # Revision 1.26 2001/09/12 08:31:42 richard
1910 # handle cases where mime type is not guessable
1911 #
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.
1914 #
1915 # Revision 1.24 2001/08/29 04:49:39 richard
1916 # didn't clean up fully after debugging :(
1917 #
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).
1921 #
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.
1925 #
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.
1929 #
1930 # Revision 1.20 2001/08/12 06:32:36 richard
1931 # using isinstance(blah, Foo) now instead of isFooType
1932 #
1933 # Revision 1.19 2001/08/07 00:24:42 richard
1934 # stupid typo
1935 #
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.
1939 #
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).
1945 #
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.
1949 #
1950 # Revision 1.15 2001/08/02 00:34:10 richard
1951 # bleah syntax error
1952 #
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.
1955 #
1956 # Revision 1.13 2001/07/30 08:12:17 richard
1957 # Added time logging and file uploading to the templates.
1958 #
1959 # Revision 1.12 2001/07/30 06:26:31 richard
1960 # Added some documentation on how the newblah works.
1961 #
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.
1970 #
1971 # Revision 1.10 2001/07/30 02:37:34 richard
1972 # Temporary measure until we have decent schema migration...
1973 #
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.
1977 #
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)
1981 #
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 :)
1984 #
1985 # Revision 1.6 2001/07/29 04:04:00 richard
1986 # Moved some code around allowing for subclassing to change behaviour.
1987 #
1988 # Revision 1.5 2001/07/28 08:16:52 richard
1989 # New issue form handles lack of note better now.
1990 #
1991 # Revision 1.4 2001/07/28 00:34:34 richard
1992 # Fixed some non-string node ids.
1993 #
1994 # Revision 1.3 2001/07/23 03:56:30 richard
1995 # oops, missed a config removal
1996 #
1997 # Revision 1.2 2001/07/22 12:09:32 richard
1998 # Final commit of Grande Splite
1999 #
2000 # Revision 1.1 2001/07/22 11:58:35 richard
2001 # More Grande Splite
2002 #
2003 #
2004 # vim: set filetype=python ts=4 sw=4 et si