06cd0d31e3b72ab28290f4c11538ba0f4581aabd
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.76 2001-12-05 14:26:44 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.keys():
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['status'] = chatting_id
331 # get the change note
332 change_note = cl.generateChangeNote(self.nodeid, changed)
334 # make the changes
335 cl.set(self.nodeid, **props)
337 # handle linked nodes and change message generation
338 self._post_editnode(self.nodeid, change_note)
340 # and some nice feedback for the user
341 if changed:
342 message = _('%(changes)s edited ok')%{'changes':
343 ', '.join(changed.keys())}
344 else:
345 message = _('nothing changed')
346 except:
347 self.db.rollback()
348 s = StringIO.StringIO()
349 traceback.print_exc(None, s)
350 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
352 # now the display
353 id = self.nodeid
354 if cl.getkey():
355 id = cl.get(id, cl.getkey())
356 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
358 nodeid = self.nodeid
360 # use the template to display the item
361 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
362 item.render(nodeid)
364 self.pagefoot()
365 showissue = shownode
366 showmsg = shownode
368 def showuser(self, message=None):
369 '''Display a user page for editing. Make sure the user is allowed
370 to edit this node, and also check for password changes.
371 '''
372 if self.user == 'anonymous':
373 raise Unauthorised
375 user = self.db.user
377 # get the username of the node being edited
378 node_user = user.get(self.nodeid, 'username')
380 if self.user not in ('admin', node_user):
381 raise Unauthorised
383 #
384 # perform any editing
385 #
386 keys = self.form.keys()
387 num_re = re.compile('^\d+$')
388 if keys:
389 try:
390 props, changed = parsePropsFromForm(self.db, user, self.form,
391 self.nodeid)
392 set_cookie = 0
393 if self.nodeid == self.getuid() and 'password' in changed:
394 password = self.form['password'].value.strip()
395 if password:
396 set_cookie = password
397 else:
398 del props['password']
399 del changed[changed.index('password')]
400 user.set(self.nodeid, **props)
401 self._post_editnode(self.nodeid)
402 # and some feedback for the user
403 message = _('%(changes)s edited ok')%{'changes':
404 ', '.join(changed.keys())}
405 except:
406 self.db.rollback()
407 s = StringIO.StringIO()
408 traceback.print_exc(None, s)
409 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
410 else:
411 set_cookie = 0
413 # fix the cookie if the password has changed
414 if set_cookie:
415 self.set_cookie(self.user, set_cookie)
417 #
418 # now the display
419 #
420 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
422 # use the template to display the item
423 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
424 item.render(self.nodeid)
425 self.pagefoot()
427 def showfile(self):
428 ''' display a file
429 '''
430 nodeid = self.nodeid
431 cl = self.db.file
432 mime_type = cl.get(nodeid, 'type')
433 if mime_type == 'message/rfc822':
434 mime_type = 'text/plain'
435 self.header(headers={'Content-Type': mime_type})
436 self.write(cl.get(nodeid, 'content'))
438 def _createnode(self):
439 ''' create a node based on the contents of the form
440 '''
441 cl = self.db.classes[self.classname]
442 props, dummy = parsePropsFromForm(self.db, cl, self.form)
444 # set status to 'unread' if not specified - a status of '- no
445 # selection -' doesn't make sense
446 if not props.has_key('status'):
447 try:
448 unread_id = self.db.status.lookup('unread')
449 except KeyError:
450 pass
451 else:
452 props['status'] = unread_id
453 return cl.create(**props)
455 def _post_editnode(self, nid, change_note=None):
456 ''' do the linking and message sending part of the node creation
457 '''
458 cn = self.classname
459 cl = self.db.classes[cn]
460 # link if necessary
461 keys = self.form.keys()
462 for key in keys:
463 if key == ':multilink':
464 value = self.form[key].value
465 if type(value) != type([]): value = [value]
466 for value in value:
467 designator, property = value.split(':')
468 link, nodeid = roundupdb.splitDesignator(designator)
469 link = self.db.classes[link]
470 value = link.get(nodeid, property)
471 value.append(nid)
472 link.set(nodeid, **{property: value})
473 elif key == ':link':
474 value = self.form[key].value
475 if type(value) != type([]): value = [value]
476 for value in value:
477 designator, property = value.split(':')
478 link, nodeid = roundupdb.splitDesignator(designator)
479 link = self.db.classes[link]
480 link.set(nodeid, **{property: nid})
482 # handle file attachments
483 files = []
484 if self.form.has_key('__file'):
485 file = self.form['__file']
486 if file.filename:
487 mime_type = mimetypes.guess_type(file.filename)[0]
488 if not mime_type:
489 mime_type = "application/octet-stream"
490 # create the new file entry
491 files.append(self.db.file.create(type=mime_type,
492 name=file.filename, content=file.file.read()))
493 # and save the reference
494 cl.set(nid, files=files)
496 #
497 # generate an edit message
498 #
500 # we don't want to do a message if none of the following is true...
501 props = cl.getprops()
502 note = None
503 if self.form.has_key('__note'):
504 note = self.form['__note']
505 note = note.value
506 if not props.has_key('messages'):
507 return
508 if not isinstance(props['messages'], hyperdb.Multilink):
509 return
510 if not props['messages'].classname == 'msg':
511 return
512 if not (len(cl.get(nid, 'nosy', [])) or note):
513 return
515 # handle the note
516 if note:
517 if '\n' in note:
518 summary = re.split(r'\n\r?', note)[0]
519 else:
520 summary = note
521 m = ['%s\n'%note]
522 else:
523 summary = _('This %(classname)s has been edited through'
524 ' the web.\n')%{'classname': cn}
525 m = [summary]
527 # append the change note
528 m.append(change_note)
530 # now create the message
531 content = '\n'.join(m)
532 message_id = self.db.msg.create(author=self.getuid(),
533 recipients=[], date=date.Date('.'), summary=summary,
534 content=content, files=files)
536 # update the messages property
537 messages = cl.get(nid, 'messages')
538 messages.append(message_id)
539 cl.set(nid, messages=messages, files=files)
541 def newnode(self, message=None):
542 ''' Add a new node to the database.
544 The form works in two modes: blank form and submission (that is,
545 the submission goes to the same URL). **Eventually this means that
546 the form will have previously entered information in it if
547 submission fails.
549 The new node will be created with the properties specified in the
550 form submission. For multilinks, multiple form entries are handled,
551 as are prop=value,value,value. You can't mix them though.
553 If the new node is to be referenced from somewhere else immediately
554 (ie. the new node is a file that is to be attached to a support
555 issue) then supply one of these arguments in addition to the usual
556 form entries:
557 :link=designator:property
558 :multilink=designator:property
559 ... which means that once the new node is created, the "property"
560 on the node given by "designator" should now reference the new
561 node's id. The node id will be appended to the multilink.
562 '''
563 cn = self.classname
564 cl = self.db.classes[cn]
566 # possibly perform a create
567 keys = self.form.keys()
568 if [i for i in keys if i[0] != ':']:
569 props = {}
570 try:
571 nid = self._createnode()
572 # handle linked nodes and change message generation
573 self._post_editnode(nid)
574 # and some nice feedback for the user
575 message = _('%(classname)s created ok')%{'classname': cn}
576 except:
577 self.db.rollback()
578 s = StringIO.StringIO()
579 traceback.print_exc(None, s)
580 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
581 self.pagehead(_('New %(classname)s')%{'classname':
582 self.classname.capitalize()}, message)
584 # call the template
585 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
586 self.classname)
587 newitem.render(self.form)
589 self.pagefoot()
590 newissue = newnode
591 newuser = newnode
593 def newfile(self, message=None):
594 ''' Add a new file to the database.
596 This form works very much the same way as newnode - it just has a
597 file upload.
598 '''
599 cn = self.classname
600 cl = self.db.classes[cn]
602 # possibly perform a create
603 keys = self.form.keys()
604 if [i for i in keys if i[0] != ':']:
605 try:
606 file = self.form['content']
607 mime_type = mimetypes.guess_type(file.filename)[0]
608 if not mime_type:
609 mime_type = "application/octet-stream"
610 # save the file
611 nid = cl.create(content=file.file.read(), type=mime_type,
612 name=file.filename)
613 # handle linked nodes
614 self._post_editnode(nid)
615 # and some nice feedback for the user
616 message = _('%(classname)s created ok')%{'classname': cn}
617 except:
618 s = StringIO.StringIO()
619 traceback.print_exc(None, s)
620 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
622 self.pagehead(_('New %(classname)s')%{'classname':
623 self.classname.capitalize()}, message)
624 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
625 self.classname)
626 newitem.render(self.form)
627 self.pagefoot()
629 def classes(self, message=None):
630 ''' display a list of all the classes in the database
631 '''
632 if self.user == 'admin':
633 self.pagehead(_('Table of classes'), message)
634 classnames = self.db.classes.keys()
635 classnames.sort()
636 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
637 for cn in classnames:
638 cl = self.db.getclass(cn)
639 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
640 for key, value in cl.properties.items():
641 if value is None: value = ''
642 else: value = str(value)
643 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
644 key, cgi.escape(value)))
645 self.write('</table>')
646 self.pagefoot()
647 else:
648 raise Unauthorised
650 def login(self, message=None, newuser_form=None, action='index'):
651 '''Display a login page.
652 '''
653 self.pagehead(_('Login to roundup'), message)
654 self.write(_('''
655 <table>
656 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
657 <form action="login_action" method=POST>
658 <input type="hidden" name="__destination_url" value="%(action)s">
659 <tr><td align=right>Login name: </td>
660 <td><input name="__login_name"></td></tr>
661 <tr><td align=right>Password: </td>
662 <td><input type="password" name="__login_password"></td></tr>
663 <tr><td></td>
664 <td><input type="submit" value="Log In"></td></tr>
665 </form>
666 ''')%locals())
667 if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
668 self.write('</table>')
669 self.pagefoot()
670 return
671 values = {'realname': '', 'organisation': '', 'address': '',
672 'phone': '', 'username': '', 'password': '', 'confirm': '',
673 'action': action}
674 if newuser_form is not None:
675 for key in newuser_form.keys():
676 values[key] = newuser_form[key].value
677 self.write(_('''
678 <p>
679 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
680 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
681 <form action="newuser_action" method=POST>
682 <input type="hidden" name="__destination_url" value="%(action)s">
683 <tr><td align=right><em>Name: </em></td>
684 <td><input name="realname" value="%(realname)s"></td></tr>
685 <tr><td align=right><em>Organisation: </em></td>
686 <td><input name="organisation" value="%(organisation)s"></td></tr>
687 <tr><td align=right>E-Mail Address: </td>
688 <td><input name="address" value="%(address)s"></td></tr>
689 <tr><td align=right><em>Phone: </em></td>
690 <td><input name="phone" value="%(phone)s"></td></tr>
691 <tr><td align=right>Preferred Login name: </td>
692 <td><input name="username" value="%(username)s"></td></tr>
693 <tr><td align=right>Password: </td>
694 <td><input type="password" name="password" value="%(password)s"></td></tr>
695 <tr><td align=right>Password Again: </td>
696 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
697 <tr><td></td>
698 <td><input type="submit" value="Register"></td></tr>
699 </form>
700 </table>
701 ''')%values)
702 self.pagefoot()
704 def login_action(self, message=None):
705 '''Attempt to log a user in and set the cookie
707 returns 0 if a page is generated as a result of this call, and
708 1 if not (ie. the login is successful
709 '''
710 if not self.form.has_key('__login_name'):
711 self.login(message=_('Username required'))
712 return 0
713 self.user = self.form['__login_name'].value
714 if self.form.has_key('__login_password'):
715 password = self.form['__login_password'].value
716 else:
717 password = ''
718 # make sure the user exists
719 try:
720 uid = self.db.user.lookup(self.user)
721 except KeyError:
722 name = self.user
723 self.make_user_anonymous()
724 action = self.form['__destination_url'].value
725 self.login(message=_('No such user "%(name)s"')%locals(),
726 action=action)
727 return 0
729 # and that the password is correct
730 pw = self.db.user.get(uid, 'password')
731 if password != pw:
732 self.make_user_anonymous()
733 action = self.form['__destination_url'].value
734 self.login(message=_('Incorrect password'), action=action)
735 return 0
737 self.set_cookie(self.user, password)
738 return 1
740 def newuser_action(self, message=None):
741 '''Attempt to create a new user based on the contents of the form
742 and then set the cookie.
744 return 1 on successful login
745 '''
746 # re-open the database as "admin"
747 self.db = self.instance.open('admin')
749 # TODO: pre-check the required fields and username key property
750 cl = self.db.user
751 try:
752 props, dummy = parsePropsFromForm(self.db, cl, self.form)
753 uid = cl.create(**props)
754 except ValueError, message:
755 action = self.form['__destination_url'].value
756 self.login(message, action=action)
757 return 0
758 self.user = cl.get(uid, 'username')
759 password = cl.get(uid, 'password')
760 self.set_cookie(self.user, self.form['password'].value)
761 return 1
763 def set_cookie(self, user, password):
764 # construct the cookie
765 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
766 if user[-1] == '=':
767 if user[-2] == '=':
768 user = user[:-2]
769 else:
770 user = user[:-1]
771 expire = Cookie._getdate(86400*365)
772 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
773 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
774 user, expire, path)})
776 def make_user_anonymous(self):
777 # make us anonymous if we can
778 try:
779 self.db.user.lookup('anonymous')
780 self.user = 'anonymous'
781 except KeyError:
782 self.user = None
784 def logout(self, message=None):
785 self.make_user_anonymous()
786 # construct the logout cookie
787 now = Cookie._getdate()
788 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
789 self.header({'Set-Cookie':
790 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
791 path)})
792 self.login()
795 def main(self):
796 '''Wrap the database accesses so we can close the database cleanly
797 '''
798 # determine the uid to use
799 self.db = self.instance.open('admin')
800 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
801 user = 'anonymous'
802 if (cookie.has_key('roundup_user') and
803 cookie['roundup_user'].value != 'deleted'):
804 cookie = cookie['roundup_user'].value
805 if len(cookie)%4:
806 cookie = cookie + '='*(4-len(cookie)%4)
807 try:
808 user, password = binascii.a2b_base64(cookie).split(':')
809 except (TypeError, binascii.Error, binascii.Incomplete):
810 # damaged cookie!
811 user, password = 'anonymous', ''
813 # make sure the user exists
814 try:
815 uid = self.db.user.lookup(user)
816 # now validate the password
817 if password != self.db.user.get(uid, 'password'):
818 user = 'anonymous'
819 except KeyError:
820 user = 'anonymous'
822 # make sure the anonymous user is valid if we're using it
823 if user == 'anonymous':
824 self.make_user_anonymous()
825 else:
826 self.user = user
828 # re-open the database for real, using the user
829 self.db = self.instance.open(self.user)
831 # now figure which function to call
832 path = self.split_path
834 # default action to index if the path has no information in it
835 if not path or path[0] in ('', 'index'):
836 action = 'index'
837 else:
838 action = path[0]
840 # Everthing ignores path[1:]
841 # - The file download link generator actually relies on this - it
842 # appends the name of the file to the URL so the download file name
843 # is correct, but doesn't actually use it.
845 # everyone is allowed to try to log in
846 if action == 'login_action':
847 # try to login
848 if not self.login_action():
849 return
850 # figure the resulting page
851 action = self.form['__destination_url'].value
852 if not action:
853 action = 'index'
854 self.do_action(action)
855 return
857 # allow anonymous people to register
858 if action == 'newuser_action':
859 # if we don't have a login and anonymous people aren't allowed to
860 # register, then spit up the login form
861 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
862 if action == 'login':
863 self.login() # go to the index after login
864 else:
865 self.login(action=action)
866 return
867 # try to add the user
868 if not self.newuser_action():
869 return
870 # figure the resulting page
871 action = self.form['__destination_url'].value
872 if not action:
873 action = 'index'
875 # no login or registration, make sure totally anonymous access is OK
876 elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
877 if action == 'login':
878 self.login() # go to the index after login
879 else:
880 self.login(action=action)
881 return
883 # just a regular action
884 self.do_action(action)
886 # commit all changes to the database
887 self.db.commit()
889 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
890 nre=re.compile(r'new(\w+)')):
891 '''Figure the user's action and do it.
892 '''
893 # here be the "normal" functionality
894 if action == 'index':
895 self.index()
896 return
897 if action == 'list_classes':
898 self.classes()
899 return
900 if action == 'login':
901 self.login()
902 return
903 if action == 'logout':
904 self.logout()
905 return
906 m = dre.match(action)
907 if m:
908 self.classname = m.group(1)
909 self.nodeid = m.group(2)
910 try:
911 cl = self.db.classes[self.classname]
912 except KeyError:
913 raise NotFound
914 try:
915 cl.get(self.nodeid, 'id')
916 except IndexError:
917 raise NotFound
918 try:
919 func = getattr(self, 'show%s'%self.classname)
920 except AttributeError:
921 raise NotFound
922 func()
923 return
924 m = nre.match(action)
925 if m:
926 self.classname = m.group(1)
927 try:
928 func = getattr(self, 'new%s'%self.classname)
929 except AttributeError:
930 raise NotFound
931 func()
932 return
933 self.classname = action
934 try:
935 self.db.getclass(self.classname)
936 except KeyError:
937 raise NotFound
938 self.list()
941 class ExtendedClient(Client):
942 '''Includes pages and page heading information that relate to the
943 extended schema.
944 '''
945 showsupport = Client.shownode
946 showtimelog = Client.shownode
947 newsupport = Client.newnode
948 newtimelog = Client.newnode
950 default_index_sort = ['-activity']
951 default_index_group = ['priority']
952 default_index_filter = ['status']
953 default_index_columns = ['activity','status','title','assignedto']
954 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
956 def pagehead(self, title, message=None):
957 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
958 machine = self.env['SERVER_NAME']
959 port = self.env['SERVER_PORT']
960 if port != '80': machine = machine + ':' + port
961 base = urlparse.urlunparse(('http', machine, url, None, None, None))
962 if message is not None:
963 message = _('<div class="system-msg">%(message)s</div>')%locals()
964 else:
965 message = ''
966 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
967 user_name = self.user or ''
968 if self.user == 'admin':
969 admin_links = _(' | <a href="list_classes">Class List</a>' \
970 ' | <a href="user">User List</a>')
971 else:
972 admin_links = ''
973 if self.user not in (None, 'anonymous'):
974 userid = self.db.user.lookup(self.user)
975 user_info = _('''
976 <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> |
977 <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> |
978 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
979 ''')%locals()
980 else:
981 user_info = _('<a href="login">Login</a>')
982 if self.user is not None:
983 add_links = _('''
984 | Add
985 <a href="newissue">Issue</a>,
986 <a href="newsupport">Support</a>,
987 <a href="newuser">User</a>
988 ''')
989 else:
990 add_links = ''
991 self.write(_('''<html><head>
992 <title>%(title)s</title>
993 <style type="text/css">%(style)s</style>
994 </head>
995 <body bgcolor=#ffffff>
996 %(message)s
997 <table width=100%% border=0 cellspacing=0 cellpadding=2>
998 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
999 <td align=right valign=bottom>%(user_name)s</td></tr>
1000 <tr class="location-bar">
1001 <td align=left>All
1002 <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>,
1003 <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>
1004 | Unassigned
1005 <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>,
1006 <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>
1007 %(add_links)s
1008 %(admin_links)s</td>
1009 <td align=right>%(user_info)s</td>
1010 </table>
1011 ''')%locals())
1013 def parsePropsFromForm(db, cl, form, nodeid=0):
1014 '''Pull properties for the given class out of the form.
1015 '''
1016 props = {}
1017 changed = {}
1018 keys = form.keys()
1019 num_re = re.compile('^\d+$')
1020 for key in keys:
1021 if not cl.properties.has_key(key):
1022 continue
1023 proptype = cl.properties[key]
1024 if isinstance(proptype, hyperdb.String):
1025 value = form[key].value.strip()
1026 elif isinstance(proptype, hyperdb.Password):
1027 value = password.Password(form[key].value.strip())
1028 elif isinstance(proptype, hyperdb.Date):
1029 value = date.Date(form[key].value.strip())
1030 elif isinstance(proptype, hyperdb.Interval):
1031 value = date.Interval(form[key].value.strip())
1032 elif isinstance(proptype, hyperdb.Link):
1033 value = form[key].value.strip()
1034 # see if it's the "no selection" choice
1035 if value == '-1':
1036 # don't set this property
1037 continue
1038 else:
1039 # handle key values
1040 link = cl.properties[key].classname
1041 if not num_re.match(value):
1042 try:
1043 value = db.classes[link].lookup(value)
1044 except KeyError:
1045 raise ValueError, _('property "%(propname)s": '
1046 '%(value)s not a %(classname)s')%{'propname':key,
1047 'value': value, 'classname': link}
1048 elif isinstance(proptype, hyperdb.Multilink):
1049 value = form[key]
1050 if type(value) != type([]):
1051 value = [i.strip() for i in value.value.split(',')]
1052 else:
1053 value = [i.value.strip() for i in value]
1054 link = cl.properties[key].classname
1055 l = []
1056 for entry in map(str, value):
1057 if entry == '': continue
1058 if not num_re.match(entry):
1059 try:
1060 entry = db.classes[link].lookup(entry)
1061 except KeyError:
1062 raise ValueError, _('property "%(propname)s": '
1063 '"%(value)s" not an entry of %(classname)s')%{
1064 'propname':key, 'value': entry, 'classname': link}
1065 l.append(entry)
1066 l.sort()
1067 value = l
1068 props[key] = value
1070 # get the old value
1071 if nodeid:
1072 try:
1073 existing = cl.get(nodeid, key)
1074 except KeyError:
1075 # this might be a new property for which there is no existing
1076 # value
1077 if not cl.properties.has_key(key): raise
1079 # if changed, set it
1080 if nodeid and value != existing:
1081 changed[key] = value
1082 props[key] = value
1083 return props, changed
1085 #
1086 # $Log: not supported by cvs2svn $
1087 # Revision 1.75 2001/12/04 01:25:08 richard
1088 # Added some rollbacks where we were catching exceptions that would otherwise
1089 # have stopped committing.
1090 #
1091 # Revision 1.74 2001/12/02 05:06:16 richard
1092 # . We now use weakrefs in the Classes to keep the database reference, so
1093 # the close() method on the database is no longer needed.
1094 # I bumped the minimum python requirement up to 2.1 accordingly.
1095 # . #487480 ] roundup-server
1096 # . #487476 ] INSTALL.txt
1097 #
1098 # I also cleaned up the change message / post-edit stuff in the cgi client.
1099 # There's now a clearly marked "TODO: append the change note" where I believe
1100 # the change note should be added there. The "changes" list will obviously
1101 # have to be modified to be a dict of the changes, or somesuch.
1102 #
1103 # More testing needed.
1104 #
1105 # Revision 1.73 2001/12/01 07:17:50 richard
1106 # . We now have basic transaction support! Information is only written to
1107 # the database when the commit() method is called. Only the anydbm
1108 # backend is modified in this way - neither of the bsddb backends have been.
1109 # The mail, admin and cgi interfaces all use commit (except the admin tool
1110 # doesn't have a commit command, so interactive users can't commit...)
1111 # . Fixed login/registration forwarding the user to the right page (or not,
1112 # on a failure)
1113 #
1114 # Revision 1.72 2001/11/30 20:47:58 rochecompaan
1115 # Links in page header are now consistent with default sort order.
1116 #
1117 # Fixed bugs:
1118 # - When login failed the list of issues were still rendered.
1119 # - User was redirected to index page and not to his destination url
1120 # if his first login attempt failed.
1121 #
1122 # Revision 1.71 2001/11/30 20:28:10 rochecompaan
1123 # Property changes are now completely traceable, whether changes are
1124 # made through the web or by email
1125 #
1126 # Revision 1.70 2001/11/30 00:06:29 richard
1127 # Converted roundup/cgi_client.py to use _()
1128 # Added the status file, I18N_PROGRESS.txt
1129 #
1130 # Revision 1.69 2001/11/29 23:19:51 richard
1131 # Removed the "This issue has been edited through the web" when a valid
1132 # change note is supplied.
1133 #
1134 # Revision 1.68 2001/11/29 04:57:23 richard
1135 # a little comment
1136 #
1137 # Revision 1.67 2001/11/28 21:55:35 richard
1138 # . login_action and newuser_action return values were being ignored
1139 # . Woohoo! Found that bloody re-login bug that was killing the mail
1140 # gateway.
1141 # (also a minor cleanup in hyperdb)
1142 #
1143 # Revision 1.66 2001/11/27 03:00:50 richard
1144 # couple of bugfixes from latest patch integration
1145 #
1146 # Revision 1.65 2001/11/26 23:00:53 richard
1147 # This config stuff is getting to be a real mess...
1148 #
1149 # Revision 1.64 2001/11/26 22:56:35 richard
1150 # typo
1151 #
1152 # Revision 1.63 2001/11/26 22:55:56 richard
1153 # Feature:
1154 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1155 # the instance.
1156 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1157 # signature info in e-mails.
1158 # . Some more flexibility in the mail gateway and more error handling.
1159 # . Login now takes you to the page you back to the were denied access to.
1160 #
1161 # Fixed:
1162 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1163 #
1164 # Revision 1.62 2001/11/24 00:45:42 jhermann
1165 # typeof() instead of type(): avoid clash with database field(?) "type"
1166 #
1167 # Fixes this traceback:
1168 #
1169 # Traceback (most recent call last):
1170 # File "roundup\cgi_client.py", line 535, in newnode
1171 # self._post_editnode(nid)
1172 # File "roundup\cgi_client.py", line 415, in _post_editnode
1173 # if type(value) != type([]): value = [value]
1174 # UnboundLocalError: local variable 'type' referenced before assignment
1175 #
1176 # Revision 1.61 2001/11/22 15:46:42 jhermann
1177 # Added module docstrings to all modules.
1178 #
1179 # Revision 1.60 2001/11/21 22:57:28 jhermann
1180 # Added dummy hooks for I18N and some preliminary (test) markup of
1181 # translatable messages
1182 #
1183 # Revision 1.59 2001/11/21 03:21:13 richard
1184 # oops
1185 #
1186 # Revision 1.58 2001/11/21 03:11:28 richard
1187 # Better handling of new properties.
1188 #
1189 # Revision 1.57 2001/11/15 10:24:27 richard
1190 # handle the case where there is no file attached
1191 #
1192 # Revision 1.56 2001/11/14 21:35:21 richard
1193 # . users may attach files to issues (and support in ext) through the web now
1194 #
1195 # Revision 1.55 2001/11/07 02:34:06 jhermann
1196 # Handling of damaged login cookies
1197 #
1198 # Revision 1.54 2001/11/07 01:16:12 richard
1199 # Remove the '=' padding from cookie value so quoting isn't an issue.
1200 #
1201 # Revision 1.53 2001/11/06 23:22:05 jhermann
1202 # More IE fixes: it does not like quotes around cookie values; in the
1203 # hope this does not break anything for other browser; if it does, we
1204 # need to check HTTP_USER_AGENT
1205 #
1206 # Revision 1.52 2001/11/06 23:11:22 jhermann
1207 # Fixed debug output in page footer; added expiry date to the login cookie
1208 # (expires 1 year in the future) to prevent probs with certain versions
1209 # of IE
1210 #
1211 # Revision 1.51 2001/11/06 22:00:34 jhermann
1212 # Get debug level from ROUNDUP_DEBUG env var
1213 #
1214 # Revision 1.50 2001/11/05 23:45:40 richard
1215 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1216 # Also made it present nicer error messages (not tracebacks).
1217 #
1218 # Revision 1.49 2001/11/04 03:07:12 richard
1219 # Fixed various cookie-related bugs:
1220 # . bug #477685 ] base64.decodestring breaks
1221 # . bug #477837 ] lynx does not like the cookie
1222 # . bug #477892 ] Password edit doesn't fix login cookie
1223 # Also closed a security hole - a logged-in user could edit another user's
1224 # details.
1225 #
1226 # Revision 1.48 2001/11/03 01:30:18 richard
1227 # Oops. uses pagefoot now.
1228 #
1229 # Revision 1.47 2001/11/03 01:29:28 richard
1230 # Login page didn't have all close tags.
1231 #
1232 # Revision 1.46 2001/11/03 01:26:55 richard
1233 # possibly fix truncated base64'ed user:pass
1234 #
1235 # Revision 1.45 2001/11/01 22:04:37 richard
1236 # Started work on supporting a pop3-fetching server
1237 # Fixed bugs:
1238 # . bug #477104 ] HTML tag error in roundup-server
1239 # . bug #477107 ] HTTP header problem
1240 #
1241 # Revision 1.44 2001/10/28 23:03:08 richard
1242 # Added more useful header to the classic schema.
1243 #
1244 # Revision 1.43 2001/10/24 00:01:42 richard
1245 # More fixes to lockout logic.
1246 #
1247 # Revision 1.42 2001/10/23 23:56:03 richard
1248 # HTML typo
1249 #
1250 # Revision 1.41 2001/10/23 23:52:35 richard
1251 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1252 #
1253 # Revision 1.40 2001/10/23 23:06:39 richard
1254 # Some cleanup.
1255 #
1256 # Revision 1.39 2001/10/23 01:00:18 richard
1257 # Re-enabled login and registration access after lopping them off via
1258 # disabling access for anonymous users.
1259 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1260 # a couple of bugs while I was there. Probably introduced a couple, but
1261 # things seem to work OK at the moment.
1262 #
1263 # Revision 1.38 2001/10/22 03:25:01 richard
1264 # Added configuration for:
1265 # . anonymous user access and registration (deny/allow)
1266 # . filter "widget" location on index page (top, bottom, both)
1267 # Updated some documentation.
1268 #
1269 # Revision 1.37 2001/10/21 07:26:35 richard
1270 # feature #473127: Filenames. I modified the file.index and htmltemplate
1271 # source so that the filename is used in the link and the creation
1272 # information is displayed.
1273 #
1274 # Revision 1.36 2001/10/21 04:44:50 richard
1275 # bug #473124: UI inconsistency with Link fields.
1276 # This also prompted me to fix a fairly long-standing usability issue -
1277 # that of being able to turn off certain filters.
1278 #
1279 # Revision 1.35 2001/10/21 00:17:54 richard
1280 # CGI interface view customisation section may now be hidden (patch from
1281 # Roch'e Compaan.)
1282 #
1283 # Revision 1.34 2001/10/20 11:58:48 richard
1284 # Catch errors in login - no username or password supplied.
1285 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1286 #
1287 # Revision 1.33 2001/10/17 00:18:41 richard
1288 # Manually constructing cookie headers now.
1289 #
1290 # Revision 1.32 2001/10/16 03:36:21 richard
1291 # CGI interface wasn't handling checkboxes at all.
1292 #
1293 # Revision 1.31 2001/10/14 10:55:00 richard
1294 # Handle empty strings in HTML template Link function
1295 #
1296 # Revision 1.30 2001/10/09 07:38:58 richard
1297 # Pushed the base code for the extended schema CGI interface back into the
1298 # code cgi_client module so that future updates will be less painful.
1299 # Also removed a debugging print statement from cgi_client.
1300 #
1301 # Revision 1.29 2001/10/09 07:25:59 richard
1302 # Added the Password property type. See "pydoc roundup.password" for
1303 # implementation details. Have updated some of the documentation too.
1304 #
1305 # Revision 1.28 2001/10/08 00:34:31 richard
1306 # Change message was stuffing up for multilinks with no key property.
1307 #
1308 # Revision 1.27 2001/10/05 02:23:24 richard
1309 # . roundup-admin create now prompts for property info if none is supplied
1310 # on the command-line.
1311 # . hyperdb Class getprops() method may now return only the mutable
1312 # properties.
1313 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1314 # now support anonymous user access (read-only, unless there's an
1315 # "anonymous" user, in which case write access is permitted). Login
1316 # handling has been moved into cgi_client.Client.main()
1317 # . The "extended" schema is now the default in roundup init.
1318 # . The schemas have had their page headings modified to cope with the new
1319 # login handling. Existing installations should copy the interfaces.py
1320 # file from the roundup lib directory to their instance home.
1321 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1322 # Ping - has been removed.
1323 # . Fixed a whole bunch of places in the CGI interface where we should have
1324 # been returning Not Found instead of throwing an exception.
1325 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1326 # an item now throws an exception.
1327 #
1328 # Revision 1.26 2001/09/12 08:31:42 richard
1329 # handle cases where mime type is not guessable
1330 #
1331 # Revision 1.25 2001/08/29 05:30:49 richard
1332 # change messages weren't being saved when there was no-one on the nosy list.
1333 #
1334 # Revision 1.24 2001/08/29 04:49:39 richard
1335 # didn't clean up fully after debugging :(
1336 #
1337 # Revision 1.23 2001/08/29 04:47:18 richard
1338 # Fixed CGI client change messages so they actually include the properties
1339 # changed (again).
1340 #
1341 # Revision 1.22 2001/08/17 00:08:10 richard
1342 # reverted back to sending messages always regardless of who is doing the web
1343 # edit. change notes weren't being saved. bleah. hackish.
1344 #
1345 # Revision 1.21 2001/08/15 23:43:18 richard
1346 # Fixed some isFooTypes that I missed.
1347 # Refactored some code in the CGI code.
1348 #
1349 # Revision 1.20 2001/08/12 06:32:36 richard
1350 # using isinstance(blah, Foo) now instead of isFooType
1351 #
1352 # Revision 1.19 2001/08/07 00:24:42 richard
1353 # stupid typo
1354 #
1355 # Revision 1.18 2001/08/07 00:15:51 richard
1356 # Added the copyright/license notice to (nearly) all files at request of
1357 # Bizar Software.
1358 #
1359 # Revision 1.17 2001/08/02 06:38:17 richard
1360 # Roundupdb now appends "mailing list" information to its messages which
1361 # include the e-mail address and web interface address. Templates may
1362 # override this in their db classes to include specific information (support
1363 # instructions, etc).
1364 #
1365 # Revision 1.16 2001/08/02 05:55:25 richard
1366 # Web edit messages aren't sent to the person who did the edit any more. No
1367 # message is generated if they are the only person on the nosy list.
1368 #
1369 # Revision 1.15 2001/08/02 00:34:10 richard
1370 # bleah syntax error
1371 #
1372 # Revision 1.14 2001/08/02 00:26:16 richard
1373 # Changed the order of the information in the message generated by web edits.
1374 #
1375 # Revision 1.13 2001/07/30 08:12:17 richard
1376 # Added time logging and file uploading to the templates.
1377 #
1378 # Revision 1.12 2001/07/30 06:26:31 richard
1379 # Added some documentation on how the newblah works.
1380 #
1381 # Revision 1.11 2001/07/30 06:17:45 richard
1382 # Features:
1383 # . Added ability for cgi newblah forms to indicate that the new node
1384 # should be linked somewhere.
1385 # Fixed:
1386 # . Fixed the agument handling for the roundup-admin find command.
1387 # . Fixed handling of summary when no note supplied for newblah. Again.
1388 # . Fixed detection of no form in htmltemplate Field display.
1389 #
1390 # Revision 1.10 2001/07/30 02:37:34 richard
1391 # Temporary measure until we have decent schema migration...
1392 #
1393 # Revision 1.9 2001/07/30 01:25:07 richard
1394 # Default implementation is now "classic" rather than "extended" as one would
1395 # expect.
1396 #
1397 # Revision 1.8 2001/07/29 08:27:40 richard
1398 # Fixed handling of passed-in values in form elements (ie. during a
1399 # drill-down)
1400 #
1401 # Revision 1.7 2001/07/29 07:01:39 richard
1402 # Added vim command to all source so that we don't get no steenkin' tabs :)
1403 #
1404 # Revision 1.6 2001/07/29 04:04:00 richard
1405 # Moved some code around allowing for subclassing to change behaviour.
1406 #
1407 # Revision 1.5 2001/07/28 08:16:52 richard
1408 # New issue form handles lack of note better now.
1409 #
1410 # Revision 1.4 2001/07/28 00:34:34 richard
1411 # Fixed some non-string node ids.
1412 #
1413 # Revision 1.3 2001/07/23 03:56:30 richard
1414 # oops, missed a config removal
1415 #
1416 # Revision 1.2 2001/07/22 12:09:32 richard
1417 # Final commit of Grande Splite
1418 #
1419 # Revision 1.1 2001/07/22 11:58:35 richard
1420 # More Grande Splite
1421 #
1422 #
1423 # vim: set filetype=python ts=4 sw=4 et si