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