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