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