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.27 2001-10-05 02:23:24 richard Exp $
20 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
21 import base64, Cookie, time
23 import roundupdb, htmltemplate, date, hyperdb
25 class Unauthorised(ValueError):
26 pass
28 class NotFound(ValueError):
29 pass
31 class Client:
32 '''
34 A note about login
35 ------------------
37 If the user has no login cookie, then they are anonymous. There
38 are two levels of anonymous use. If there is no 'anonymous' user, there
39 is no login at all and the database is opened in read-only mode. If the
40 'anonymous' user exists, the user is logged in using that user (though
41 there is no cookie). This allows them to modify the database, and all
42 modifications are attributed to the 'anonymous' user.
43 '''
45 def __init__(self, instance, out, env):
46 self.instance = instance
47 self.out = out
48 self.env = env
49 self.path = env['PATH_INFO']
50 self.split_path = self.path.split('/')
52 self.headers_done = 0
53 self.form = cgi.FieldStorage(environ=env)
54 self.headers_done = 0
55 self.debug = 0
57 def getuid(self):
58 return self.db.user.lookup(self.user)
60 def header(self, headers={'Content-Type':'text/html'}):
61 if not headers.has_key('Content-Type'):
62 headers['Content-Type'] = 'text/html'
63 for entry in headers.items():
64 self.out.write('%s: %s\n'%entry)
65 self.out.write('\n')
66 self.headers_done = 1
68 def pagehead(self, title, message=None):
69 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
70 machine = self.env['SERVER_NAME']
71 port = self.env['SERVER_PORT']
72 if port != '80': machine = machine + ':' + port
73 base = urlparse.urlunparse(('http', machine, url, None, None, None))
74 if message is not None:
75 message = '<div class="system-msg">%s</div>'%message
76 else:
77 message = ''
78 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
79 if self.user is not None:
80 userid = self.db.user.lookup(self.user)
81 user_info = '(login: <a href="user%s">%s</a>)'%(userid, self.user)
82 else:
83 user_info = ''
84 self.write('''<html><head>
85 <title>%s</title>
86 <style type="text/css">%s</style>
87 </head>
88 <body bgcolor=#ffffff>
89 %s
90 <table width=100%% border=0 cellspacing=0 cellpadding=2>
91 <tr class="location-bar"><td><big><strong>%s</strong></big> %s</td></tr>
92 </table>
93 '''%(title, style, message, title, user_info))
95 def pagefoot(self):
96 if self.debug:
97 self.write('<hr><small><dl>')
98 self.write('<dt><b>Path</b></dt>')
99 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
100 keys = self.form.keys()
101 keys.sort()
102 if keys:
103 self.write('<dt><b>Form entries</b></dt>')
104 for k in self.form.keys():
105 v = str(self.form[k].value)
106 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
107 keys = self.env.keys()
108 keys.sort()
109 self.write('<dt><b>CGI environment</b></dt>')
110 for k in keys:
111 v = self.env[k]
112 self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
113 self.write('</dl></small>')
114 self.write('</body></html>')
116 def write(self, content):
117 if not self.headers_done:
118 self.header()
119 self.out.write(content)
121 def index_arg(self, arg):
122 ''' handle the args to index - they might be a list from the form
123 (ie. submitted from a form) or they might be a command-separated
124 single string (ie. manually constructed GET args)
125 '''
126 if self.form.has_key(arg):
127 arg = self.form[arg]
128 if type(arg) == type([]):
129 return [arg.value for arg in arg]
130 return arg.value.split(',')
131 return []
133 def index_filterspec(self):
134 ''' pull the index filter spec from the form
136 Links and multilinks want to be lists - the rest are straight
137 strings.
138 '''
139 props = self.db.classes[self.classname].getprops()
140 # all the form args not starting with ':' are filters
141 filterspec = {}
142 for key in self.form.keys():
143 if key[0] == ':': continue
144 if not props.has_key(key): continue
145 prop = props[key]
146 value = self.form[key]
147 if (isinstance(prop, hyperdb.Link) or
148 isinstance(prop, hyperdb.Multilink)):
149 if type(value) == type([]):
150 value = [arg.value for arg in value]
151 else:
152 value = value.value.split(',')
153 l = filterspec.get(key, [])
154 l = l + value
155 filterspec[key] = l
156 else:
157 filterspec[key] = value.value
158 return filterspec
160 default_index_sort = ['-activity']
161 default_index_group = ['priority']
162 default_index_filter = []
163 default_index_columns = ['id','activity','title','status','assignedto']
164 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
165 def index(self):
166 ''' put up an index
167 '''
168 self.classname = 'issue'
169 if self.form.has_key(':sort'): sort = self.index_arg(':sort')
170 else: sort = self.default_index_sort
171 if self.form.has_key(':group'): group = self.index_arg(':group')
172 else: group = self.default_index_group
173 if self.form.has_key(':filter'): filter = self.index_arg(':filter')
174 else: filter = self.default_index_filter
175 if self.form.has_key(':columns'): columns = self.index_arg(':columns')
176 else: columns = self.default_index_columns
177 filterspec = self.index_filterspec()
178 if not filterspec:
179 filterspec = self.default_index_filterspec
180 return self.list(columns=columns, filter=filter, group=group,
181 sort=sort, filterspec=filterspec)
183 # XXX deviates from spec - loses the '+' (that's a reserved character
184 # in URLS
185 def list(self, sort=None, group=None, filter=None, columns=None,
186 filterspec=None):
187 ''' call the template index with the args
189 :sort - sort by prop name, optionally preceeded with '-'
190 to give descending or nothing for ascending sorting.
191 :group - group by prop name, optionally preceeded with '-' or
192 to sort in descending or nothing for ascending order.
193 :filter - selects which props should be displayed in the filter
194 section. Default is all.
195 :columns - selects the columns that should be displayed.
196 Default is all.
198 '''
199 cn = self.classname
200 self.pagehead('Index of %s'%cn)
201 if sort is None: sort = self.index_arg(':sort')
202 if group is None: group = self.index_arg(':group')
203 if filter is None: filter = self.index_arg(':filter')
204 if columns is None: columns = self.index_arg(':columns')
205 if filterspec is None: filterspec = self.index_filterspec()
207 htmltemplate.index(self, self.TEMPLATES, self.db, cn, filterspec,
208 filter, columns, sort, group)
209 self.pagefoot()
211 def shownode(self, message=None):
212 ''' display an item
213 '''
214 cn = self.classname
215 cl = self.db.classes[cn]
217 # possibly perform an edit
218 keys = self.form.keys()
219 num_re = re.compile('^\d+$')
220 if keys:
221 try:
222 props, changed = parsePropsFromForm(cl, self.form, self.nodeid)
223 cl.set(self.nodeid, **props)
224 self._post_editnode(self.nodeid, changed)
225 # and some nice feedback for the user
226 message = '%s edited ok'%', '.join(changed)
227 except:
228 s = StringIO.StringIO()
229 traceback.print_exc(None, s)
230 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
232 # now the display
233 id = self.nodeid
234 if cl.getkey():
235 id = cl.get(id, cl.getkey())
236 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
238 nodeid = self.nodeid
240 # use the template to display the item
241 htmltemplate.item(self, self.TEMPLATES, self.db, self.classname, nodeid)
242 self.pagefoot()
243 showissue = shownode
244 showmsg = shownode
246 def showuser(self, message=None):
247 ''' display an item
248 '''
249 if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
250 self.shownode(message)
251 else:
252 raise Unauthorised
254 def showfile(self):
255 ''' display a file
256 '''
257 nodeid = self.nodeid
258 cl = self.db.file
259 type = cl.get(nodeid, 'type')
260 if type == 'message/rfc822':
261 type = 'text/plain'
262 self.header(headers={'Content-Type': type})
263 self.write(cl.get(nodeid, 'content'))
265 def _createnode(self):
266 ''' create a node based on the contents of the form
267 '''
268 cl = self.db.classes[self.classname]
269 props, dummy = parsePropsFromForm(cl, self.form)
270 return cl.create(**props)
272 def _post_editnode(self, nid, changes=None):
273 ''' do the linking and message sending part of the node creation
274 '''
275 cn = self.classname
276 cl = self.db.classes[cn]
277 # link if necessary
278 keys = self.form.keys()
279 for key in keys:
280 if key == ':multilink':
281 value = self.form[key].value
282 if type(value) != type([]): value = [value]
283 for value in value:
284 designator, property = value.split(':')
285 link, nodeid = roundupdb.splitDesignator(designator)
286 link = self.db.classes[link]
287 value = link.get(nodeid, property)
288 value.append(nid)
289 link.set(nodeid, **{property: value})
290 elif key == ':link':
291 value = self.form[key].value
292 if type(value) != type([]): value = [value]
293 for value in value:
294 designator, property = value.split(':')
295 link, nodeid = roundupdb.splitDesignator(designator)
296 link = self.db.classes[link]
297 link.set(nodeid, **{property: nid})
299 # generate an edit message
300 # don't bother if there's no messages or nosy list
301 props = cl.getprops()
302 note = None
303 if self.form.has_key('__note'):
304 note = self.form['__note']
305 note = note.value
306 send = len(cl.get(nid, 'nosy', [])) or note
307 if (send and props.has_key('messages') and
308 isinstance(props['messages'], hyperdb.Multilink) and
309 props['messages'].classname == 'msg'):
311 # handle the note
312 if note:
313 if '\n' in note:
314 summary = re.split(r'\n\r?', note)[0]
315 else:
316 summary = note
317 m = ['%s\n'%note]
318 else:
319 summary = 'This %s has been edited through the web.\n'%cn
320 m = [summary]
322 first = 1
323 for name, prop in props.items():
324 if changes is not None and name not in changes: continue
325 if first:
326 m.append('\n-------')
327 first = 0
328 value = cl.get(nid, name, None)
329 if isinstance(prop, hyperdb.Link):
330 link = self.db.classes[prop.classname]
331 key = link.labelprop(default_to_id=1)
332 if value is not None and key:
333 value = link.get(value, key)
334 else:
335 value = '-'
336 elif isinstance(prop, hyperdb.Multilink):
337 if value is None: value = []
338 l = []
339 link = self.db.classes[prop.classname]
340 key = link.labelprop(default_to_id=1)
341 for entry in value:
342 if key:
343 l.append(link.get(entry, link.getkey()))
344 else:
345 l.append(entry)
346 value = ', '.join(l)
347 m.append('%s: %s'%(name, value))
349 # now create the message
350 content = '\n'.join(m)
351 message_id = self.db.msg.create(author=self.getuid(),
352 recipients=[], date=date.Date('.'), summary=summary,
353 content=content)
354 messages = cl.get(nid, 'messages')
355 messages.append(message_id)
356 props = {'messages': messages}
357 cl.set(nid, **props)
359 def newnode(self, message=None):
360 ''' Add a new node to the database.
362 The form works in two modes: blank form and submission (that is,
363 the submission goes to the same URL). **Eventually this means that
364 the form will have previously entered information in it if
365 submission fails.
367 The new node will be created with the properties specified in the
368 form submission. For multilinks, multiple form entries are handled,
369 as are prop=value,value,value. You can't mix them though.
371 If the new node is to be referenced from somewhere else immediately
372 (ie. the new node is a file that is to be attached to a support
373 issue) then supply one of these arguments in addition to the usual
374 form entries:
375 :link=designator:property
376 :multilink=designator:property
377 ... which means that once the new node is created, the "property"
378 on the node given by "designator" should now reference the new
379 node's id. The node id will be appended to the multilink.
380 '''
381 cn = self.classname
382 cl = self.db.classes[cn]
384 # possibly perform a create
385 keys = self.form.keys()
386 if [i for i in keys if i[0] != ':']:
387 props = {}
388 try:
389 nid = self._createnode()
390 self._post_editnode(nid)
391 # and some nice feedback for the user
392 message = '%s created ok'%cn
393 except:
394 s = StringIO.StringIO()
395 traceback.print_exc(None, s)
396 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
397 self.pagehead('New %s'%self.classname.capitalize(), message)
398 htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
399 self.form)
400 self.pagefoot()
401 newissue = newnode
402 newuser = newnode
404 def newfile(self, message=None):
405 ''' Add a new file to the database.
407 This form works very much the same way as newnode - it just has a
408 file upload.
409 '''
410 cn = self.classname
411 cl = self.db.classes[cn]
413 # possibly perform a create
414 keys = self.form.keys()
415 if [i for i in keys if i[0] != ':']:
416 try:
417 file = self.form['content']
418 type = mimetypes.guess_type(file.filename)[0]
419 if not type:
420 type = "application/octet-stream"
421 self._post_editnode(cl.create(content=file.file.read(),
422 type=type, name=file.filename))
423 # and some nice feedback for the user
424 message = '%s created ok'%cn
425 except:
426 s = StringIO.StringIO()
427 traceback.print_exc(None, s)
428 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
430 self.pagehead('New %s'%self.classname.capitalize(), message)
431 htmltemplate.newitem(self, self.TEMPLATES, self.db, self.classname,
432 self.form)
433 self.pagefoot()
435 def classes(self, message=None):
436 ''' display a list of all the classes in the database
437 '''
438 if self.user == 'admin':
439 self.pagehead('Table of classes', message)
440 classnames = self.db.classes.keys()
441 classnames.sort()
442 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
443 for cn in classnames:
444 cl = self.db.getclass(cn)
445 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
446 for key, value in cl.properties.items():
447 if value is None: value = ''
448 else: value = str(value)
449 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
450 key, cgi.escape(value)))
451 self.write('</table>')
452 self.pagefoot()
453 else:
454 raise Unauthorised
456 def login(self, message=None):
457 self.pagehead('Login to roundup', message)
458 self.write('''
459 <table>
460 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
461 <form action="login_action" method=POST>
462 <tr><td align=right>Login name: </td>
463 <td><input name="__login_name"></td></tr>
464 <tr><td align=right>Password: </td>
465 <td><input type="password" name="__login_password"></td></tr>
466 <tr><td></td>
467 <td><input type="submit" value="Log In"></td></tr>
468 </form>
470 <p>
471 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
472 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
473 <form action="newuser_action" method=POST>
474 <tr><td align=right><em>Name: </em></td>
475 <td><input name="__newuser_realname"></td></tr>
476 <tr><td align=right><em>Organisation: </em></td>
477 <td><input name="__newuser_organisation"></td></tr>
478 <tr><td align=right>E-Mail Address: </td>
479 <td><input name="__newuser_address"></td></tr>
480 <tr><td align=right><em>Phone: </em></td>
481 <td><input name="__newuser_phone"></td></tr>
482 <tr><td align=right>Preferred Login name: </td>
483 <td><input name="__newuser_username"></td></tr>
484 <tr><td align=right>Password: </td>
485 <td><input type="password" name="__newuser_password"></td></tr>
486 <tr><td align=right>Password Again: </td>
487 <td><input type="password" name="__newuser_confirm"></td></tr>
488 <tr><td></td>
489 <td><input type="submit" value="Register"></td></tr>
490 </form>
491 </table>
492 ''')
494 def login_action(self, message=None):
495 self.user = self.form['__login_name'].value
496 password = self.form['__login_password'].value
497 # make sure the user exists
498 try:
499 uid = self.db.user.lookup(self.user)
500 except KeyError:
501 name = self.user
502 self.make_user_anonymous()
503 return self.login(message='No such user "%s"'%name)
505 # and that the password is correct
506 if password != self.db.user.get(uid, 'password'):
507 return self.login(message='Incorrect password')
509 # construct the cookie
510 uid = self.db.user.lookup(self.user)
511 user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
512 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
513 ''))
514 cookie = Cookie.SmartCookie()
515 cookie['roundup_user'] = user
516 cookie['roundup_user']['path'] = path
517 self.header({'Set-Cookie': str(cookie)})
518 return self.index()
520 def make_user_anonymous(self):
521 # make us anonymous if we can
522 try:
523 self.db.user.lookup('anonymous')
524 self.user = 'anonymous'
525 except KeyError:
526 self.user = None
528 def logout(self, message=None):
529 self.make_user_anonymous()
530 # construct the logout cookie
531 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
532 ''))
533 cookie = Cookie.SmartCookie()
534 cookie['roundup_user'] = 'deleted'
535 cookie['roundup_user']['path'] = path
536 cookie['roundup_user']['expires'] = 0
537 cookie['roundup_user']['max-age'] = 0
538 self.header({'Set-Cookie': str(cookie)})
539 return self.index()
541 def newuser_action(self, message=None):
542 ''' create a new user based on the contents of the form and then
543 set the cookie
544 '''
545 # TODO: pre-check the required fields and username key property
546 cl = self.db.classes['user']
547 props, dummy = parsePropsFromForm(cl, self.form)
548 uid = cl.create(**props)
549 self.user = self.db.user.get(uid, 'username')
550 password = self.db.user.get(uid, 'password')
551 # construct the cookie
552 uid = self.db.user.lookup(self.user)
553 user = base64.encodestring('%s:%s'%(self.user, password))[:-1]
554 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
555 ''))
556 cookie = Cookie.SmartCookie()
557 cookie['roundup_user'] = user
558 cookie['roundup_user']['path'] = path
559 self.header({'Set-Cookie': str(cookie)})
560 return self.index()
562 def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
563 nre=re.compile(r'new(\w+)')):
565 # determine the uid to use
566 self.db = self.instance.open('admin')
567 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
568 user = 'anonymous'
569 if (cookie.has_key('roundup_user') and
570 cookie['roundup_user'].value != 'deleted'):
571 cookie = cookie['roundup_user'].value
572 user, password = base64.decodestring(cookie).split(':')
573 # make sure the user exists
574 try:
575 uid = self.db.user.lookup(user)
576 # now validate the password
577 if password != self.db.user.get(uid, 'password'):
578 user = 'anonymous'
579 except KeyError:
580 user = 'anonymous'
582 # make sure the anonymous user is valid if we're using it
583 if user == 'anonymous':
584 self.make_user_anonymous()
585 else:
586 self.user = user
587 self.db.close()
589 # re-open the database for real, using the user
590 self.db = self.instance.open(self.user)
592 # now figure which function to call
593 path = self.split_path
594 if not path or path[0] in ('', 'index'):
595 self.index()
596 elif len(path) == 1:
597 if path[0] == 'list_classes':
598 self.classes()
599 return
600 if path[0] == 'login':
601 self.login()
602 return
603 if path[0] == 'login_action':
604 self.login_action()
605 return
606 if path[0] == 'newuser_action':
607 self.newuser_action()
608 return
609 if path[0] == 'logout':
610 self.logout()
611 return
612 m = dre.match(path[0])
613 if m:
614 self.classname = m.group(1)
615 self.nodeid = m.group(2)
616 try:
617 cl = self.db.classes[self.classname]
618 except KeyError:
619 raise NotFound
620 try:
621 cl.get(self.nodeid, 'id')
622 except IndexError:
623 raise NotFound
624 try:
625 getattr(self, 'show%s'%self.classname)()
626 except AttributeError:
627 raise NotFound
628 return
629 m = nre.match(path[0])
630 if m:
631 self.classname = m.group(1)
632 try:
633 getattr(self, 'new%s'%self.classname)()
634 except AttributeError:
635 raise NotFound
636 return
637 self.classname = path[0]
638 try:
639 self.db.getclass(self.classname)
640 except KeyError:
641 raise NotFound
642 self.list()
643 else:
644 raise 'ValueError', 'Path not understood'
646 def __del__(self):
647 self.db.close()
649 def parsePropsFromForm(cl, form, nodeid=0):
650 '''Pull properties for the given class out of the form.
651 '''
652 props = {}
653 changed = []
654 keys = form.keys()
655 num_re = re.compile('^\d+$')
656 for key in keys:
657 if not cl.properties.has_key(key):
658 continue
659 proptype = cl.properties[key]
660 if isinstance(proptype, hyperdb.String):
661 value = form[key].value.strip()
662 elif isinstance(proptype, hyperdb.Date):
663 value = date.Date(form[key].value.strip())
664 elif isinstance(proptype, hyperdb.Interval):
665 value = date.Interval(form[key].value.strip())
666 elif isinstance(proptype, hyperdb.Link):
667 value = form[key].value.strip()
668 # handle key values
669 link = cl.properties[key].classname
670 if not num_re.match(value):
671 try:
672 value = self.db.classes[link].lookup(value)
673 except:
674 raise ValueError, 'property "%s": %s not a %s'%(
675 key, value, link)
676 elif isinstance(proptype, hyperdb.Multilink):
677 value = form[key]
678 if type(value) != type([]):
679 value = [i.strip() for i in value.value.split(',')]
680 else:
681 value = [i.value.strip() for i in value]
682 link = cl.properties[key].classname
683 l = []
684 for entry in map(str, value):
685 if not num_re.match(entry):
686 try:
687 entry = self.db.classes[link].lookup(entry)
688 except:
689 raise ValueError, \
690 'property "%s": %s not a %s'%(key,
691 entry, link)
692 l.append(entry)
693 l.sort()
694 value = l
695 props[key] = value
696 # if changed, set it
697 if nodeid and value != cl.get(nodeid, key):
698 changed.append(key)
699 props[key] = value
700 return props, changed
702 #
703 # $Log: not supported by cvs2svn $
704 # Revision 1.26 2001/09/12 08:31:42 richard
705 # handle cases where mime type is not guessable
706 #
707 # Revision 1.25 2001/08/29 05:30:49 richard
708 # change messages weren't being saved when there was no-one on the nosy list.
709 #
710 # Revision 1.24 2001/08/29 04:49:39 richard
711 # didn't clean up fully after debugging :(
712 #
713 # Revision 1.23 2001/08/29 04:47:18 richard
714 # Fixed CGI client change messages so they actually include the properties
715 # changed (again).
716 #
717 # Revision 1.22 2001/08/17 00:08:10 richard
718 # reverted back to sending messages always regardless of who is doing the web
719 # edit. change notes weren't being saved. bleah. hackish.
720 #
721 # Revision 1.21 2001/08/15 23:43:18 richard
722 # Fixed some isFooTypes that I missed.
723 # Refactored some code in the CGI code.
724 #
725 # Revision 1.20 2001/08/12 06:32:36 richard
726 # using isinstance(blah, Foo) now instead of isFooType
727 #
728 # Revision 1.19 2001/08/07 00:24:42 richard
729 # stupid typo
730 #
731 # Revision 1.18 2001/08/07 00:15:51 richard
732 # Added the copyright/license notice to (nearly) all files at request of
733 # Bizar Software.
734 #
735 # Revision 1.17 2001/08/02 06:38:17 richard
736 # Roundupdb now appends "mailing list" information to its messages which
737 # include the e-mail address and web interface address. Templates may
738 # override this in their db classes to include specific information (support
739 # instructions, etc).
740 #
741 # Revision 1.16 2001/08/02 05:55:25 richard
742 # Web edit messages aren't sent to the person who did the edit any more. No
743 # message is generated if they are the only person on the nosy list.
744 #
745 # Revision 1.15 2001/08/02 00:34:10 richard
746 # bleah syntax error
747 #
748 # Revision 1.14 2001/08/02 00:26:16 richard
749 # Changed the order of the information in the message generated by web edits.
750 #
751 # Revision 1.13 2001/07/30 08:12:17 richard
752 # Added time logging and file uploading to the templates.
753 #
754 # Revision 1.12 2001/07/30 06:26:31 richard
755 # Added some documentation on how the newblah works.
756 #
757 # Revision 1.11 2001/07/30 06:17:45 richard
758 # Features:
759 # . Added ability for cgi newblah forms to indicate that the new node
760 # should be linked somewhere.
761 # Fixed:
762 # . Fixed the agument handling for the roundup-admin find command.
763 # . Fixed handling of summary when no note supplied for newblah. Again.
764 # . Fixed detection of no form in htmltemplate Field display.
765 #
766 # Revision 1.10 2001/07/30 02:37:34 richard
767 # Temporary measure until we have decent schema migration...
768 #
769 # Revision 1.9 2001/07/30 01:25:07 richard
770 # Default implementation is now "classic" rather than "extended" as one would
771 # expect.
772 #
773 # Revision 1.8 2001/07/29 08:27:40 richard
774 # Fixed handling of passed-in values in form elements (ie. during a
775 # drill-down)
776 #
777 # Revision 1.7 2001/07/29 07:01:39 richard
778 # Added vim command to all source so that we don't get no steenkin' tabs :)
779 #
780 # Revision 1.6 2001/07/29 04:04:00 richard
781 # Moved some code around allowing for subclassing to change behaviour.
782 #
783 # Revision 1.5 2001/07/28 08:16:52 richard
784 # New issue form handles lack of note better now.
785 #
786 # Revision 1.4 2001/07/28 00:34:34 richard
787 # Fixed some non-string node ids.
788 #
789 # Revision 1.3 2001/07/23 03:56:30 richard
790 # oops, missed a config removal
791 #
792 # Revision 1.2 2001/07/22 12:09:32 richard
793 # Final commit of Grande Splite
794 #
795 # Revision 1.1 2001/07/22 11:58:35 richard
796 # More Grande Splite
797 #
798 #
799 # vim: set filetype=python ts=4 sw=4 et si