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