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