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