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