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