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