ba3dc94fdb5a548e748df6e1ee76d70babadd661
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.58 2001-11-21 03:11:28 richard Exp $
20 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
21 import binascii, Cookie, time
23 import roundupdb, htmltemplate, date, hyperdb, password
25 class Unauthorised(ValueError):
26 pass
28 class NotFound(ValueError):
29 pass
31 class Client:
32 '''
33 A note about login
34 ------------------
36 If the user has no login cookie, then they are anonymous. There
37 are two levels of anonymous use. If there is no 'anonymous' user, there
38 is no login at all and the database is opened in read-only mode. If the
39 'anonymous' user exists, the user is logged in using that user (though
40 there is no cookie). This allows them to modify the database, and all
41 modifications are attributed to the 'anonymous' user.
44 Customisation
45 -------------
46 FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
47 ANONYMOUS_ACCESS - one of 'deny', 'allow'
48 ANONYMOUS_REGISTER - one of 'deny', 'allow'
50 '''
51 FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom'
52 ANONYMOUS_ACCESS = 'deny' # one of 'deny', 'allow'
53 ANONYMOUS_REGISTER = 'deny' # one of 'deny', 'allow'
55 def __init__(self, instance, request, env):
56 self.instance = instance
57 self.request = request
58 self.env = env
59 self.path = env['PATH_INFO']
60 self.split_path = self.path.split('/')
62 self.form = cgi.FieldStorage(environ=env)
63 self.headers_done = 0
64 try:
65 self.debug = int(env.get("ROUNDUP_DEBUG", 0))
66 except ValueError:
67 # someone gave us a non-int debug level, turn it off
68 self.debug = 0
70 def getuid(self):
71 return self.db.user.lookup(self.user)
73 def header(self, headers={'Content-Type':'text/html'}):
74 '''Put up the appropriate header.
75 '''
76 if not headers.has_key('Content-Type'):
77 headers['Content-Type'] = 'text/html'
78 self.request.send_response(200)
79 for entry in headers.items():
80 self.request.send_header(*entry)
81 self.request.end_headers()
82 self.headers_done = 1
83 if self.debug:
84 self.headers_sent = headers
86 def pagehead(self, title, message=None):
87 url = self.env['SCRIPT_NAME'] + '/'
88 machine = self.env['SERVER_NAME']
89 port = self.env['SERVER_PORT']
90 if port != '80': machine = machine + ':' + port
91 base = urlparse.urlunparse(('http', machine, url, None, None, None))
92 if message is not None:
93 message = '<div class="system-msg">%s</div>'%message
94 else:
95 message = ''
96 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
97 user_name = self.user or ''
98 if self.user == 'admin':
99 admin_links = ' | <a href="list_classes">Class List</a>'
100 else:
101 admin_links = ''
102 if self.user not in (None, 'anonymous'):
103 userid = self.db.user.lookup(self.user)
104 user_info = '''
105 <a href="issue?assignedto=%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> |
106 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
107 '''%(userid, userid)
108 else:
109 user_info = '<a href="login">Login</a>'
110 if self.user is not None:
111 add_links = '''
112 | Add
113 <a href="newissue">Issue</a>,
114 <a href="newuser">User</a>
115 '''
116 else:
117 add_links = ''
118 self.write('''<html><head>
119 <title>%s</title>
120 <style type="text/css">%s</style>
121 </head>
122 <body bgcolor=#ffffff>
123 %s
124 <table width=100%% border=0 cellspacing=0 cellpadding=2>
125 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
126 <td align=right valign=bottom>%s</td></tr>
127 <tr class="location-bar">
128 <td align=left>All
129 <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>
130 | Unassigned
131 <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>
132 %s
133 %s</td>
134 <td align=right>%s</td>
135 </table>
136 '''%(title, style, message, title, user_name, add_links, admin_links,
137 user_info))
139 def pagefoot(self):
140 if self.debug:
141 self.write('<hr><small><dl>')
142 self.write('<dt><b>Path</b></dt>')
143 self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
144 keys = self.form.keys()
145 keys.sort()
146 if keys:
147 self.write('<dt><b>Form entries</b></dt>')
148 for k in self.form.keys():
149 v = self.form.getvalue(k, "<empty>")
150 if type(v) is type([]):
151 # Multiple username fields specified
152 v = "|".join(v)
153 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
154 keys = self.headers_sent.keys()
155 keys.sort()
156 self.write('<dt><b>Sent these HTTP headers</b></dt>')
157 for k in keys:
158 v = self.headers_sent[k]
159 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
160 keys = self.env.keys()
161 keys.sort()
162 self.write('<dt><b>CGI environment</b></dt>')
163 for k in keys:
164 v = self.env[k]
165 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
166 self.write('</dl></small>')
167 self.write('</body></html>')
169 def write(self, content):
170 if not self.headers_done:
171 self.header()
172 self.request.wfile.write(content)
174 def index_arg(self, arg):
175 ''' handle the args to index - they might be a list from the form
176 (ie. submitted from a form) or they might be a command-separated
177 single string (ie. manually constructed GET args)
178 '''
179 if self.form.has_key(arg):
180 arg = self.form[arg]
181 if type(arg) == type([]):
182 return [arg.value for arg in arg]
183 return arg.value.split(',')
184 return []
186 def index_filterspec(self, filter):
187 ''' pull the index filter spec from the form
189 Links and multilinks want to be lists - the rest are straight
190 strings.
191 '''
192 props = self.db.classes[self.classname].getprops()
193 # all the form args not starting with ':' are filters
194 filterspec = {}
195 for key in self.form.keys():
196 if key[0] == ':': continue
197 if not props.has_key(key): continue
198 if key not in filter: continue
199 prop = props[key]
200 value = self.form[key]
201 if (isinstance(prop, hyperdb.Link) or
202 isinstance(prop, hyperdb.Multilink)):
203 if type(value) == type([]):
204 value = [arg.value for arg in value]
205 else:
206 value = value.value.split(',')
207 l = filterspec.get(key, [])
208 l = l + value
209 filterspec[key] = l
210 else:
211 filterspec[key] = value.value
212 return filterspec
214 def customization_widget(self):
215 ''' The customization widget is visible by default. The widget
216 visibility is remembered by show_customization. Visibility
217 is not toggled if the action value is "Redisplay"
218 '''
219 if not self.form.has_key('show_customization'):
220 visible = 1
221 else:
222 visible = int(self.form['show_customization'].value)
223 if self.form.has_key('action'):
224 if self.form['action'].value != 'Redisplay':
225 visible = self.form['action'].value == '+'
227 return visible
229 default_index_sort = ['-activity']
230 default_index_group = ['priority']
231 default_index_filter = ['status']
232 default_index_columns = ['id','activity','title','status','assignedto']
233 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
234 def index(self):
235 ''' put up an index
236 '''
237 self.classname = 'issue'
238 # see if the web has supplied us with any customisation info
239 defaults = 1
240 for key in ':sort', ':group', ':filter', ':columns':
241 if self.form.has_key(key):
242 defaults = 0
243 break
244 if defaults:
245 # no info supplied - use the defaults
246 sort = self.default_index_sort
247 group = self.default_index_group
248 filter = self.default_index_filter
249 columns = self.default_index_columns
250 filterspec = self.default_index_filterspec
251 else:
252 sort = self.index_arg(':sort')
253 group = self.index_arg(':group')
254 filter = self.index_arg(':filter')
255 columns = self.index_arg(':columns')
256 filterspec = self.index_filterspec(filter)
257 return self.list(columns=columns, filter=filter, group=group,
258 sort=sort, filterspec=filterspec)
260 # XXX deviates from spec - loses the '+' (that's a reserved character
261 # in URLS
262 def list(self, sort=None, group=None, filter=None, columns=None,
263 filterspec=None, show_customization=None):
264 ''' call the template index with the args
266 :sort - sort by prop name, optionally preceeded with '-'
267 to give descending or nothing for ascending sorting.
268 :group - group by prop name, optionally preceeded with '-' or
269 to sort in descending or nothing for ascending order.
270 :filter - selects which props should be displayed in the filter
271 section. Default is all.
272 :columns - selects the columns that should be displayed.
273 Default is all.
275 '''
276 cn = self.classname
277 self.pagehead('Index of %s'%cn)
278 if sort is None: sort = self.index_arg(':sort')
279 if group is None: group = self.index_arg(':group')
280 if filter is None: filter = self.index_arg(':filter')
281 if columns is None: columns = self.index_arg(':columns')
282 if filterspec is None: filterspec = self.index_filterspec(filter)
283 if show_customization is None:
284 show_customization = self.customization_widget()
286 index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
287 index.render(filterspec, filter, columns, sort, group,
288 show_customization=show_customization)
289 self.pagefoot()
291 def shownode(self, message=None):
292 ''' display an item
293 '''
294 cn = self.classname
295 cl = self.db.classes[cn]
297 # possibly perform an edit
298 keys = self.form.keys()
299 num_re = re.compile('^\d+$')
300 if keys:
301 try:
302 props, changed = parsePropsFromForm(self.db, cl, self.form,
303 self.nodeid)
304 cl.set(self.nodeid, **props)
305 self._post_editnode(self.nodeid, changed)
306 # and some nice feedback for the user
307 message = '%s edited ok'%', '.join(changed)
308 except:
309 s = StringIO.StringIO()
310 traceback.print_exc(None, s)
311 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
313 # now the display
314 id = self.nodeid
315 if cl.getkey():
316 id = cl.get(id, cl.getkey())
317 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
319 nodeid = self.nodeid
321 # use the template to display the item
322 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
323 item.render(nodeid)
325 self.pagefoot()
326 showissue = shownode
327 showmsg = shownode
329 def showuser(self, message=None):
330 '''Display a user page for editing. Make sure the user is allowed
331 to edit this node, and also check for password changes.
332 '''
333 if self.user == 'anonymous':
334 raise Unauthorised
336 user = self.db.user
338 # get the username of the node being edited
339 node_user = user.get(self.nodeid, 'username')
341 if self.user not in ('admin', node_user):
342 raise Unauthorised
344 #
345 # perform any editing
346 #
347 keys = self.form.keys()
348 num_re = re.compile('^\d+$')
349 if keys:
350 try:
351 props, changed = parsePropsFromForm(self.db, user, self.form,
352 self.nodeid)
353 if self.nodeid == self.getuid() and 'password' in changed:
354 set_cookie = self.form['password'].value.strip()
355 else:
356 set_cookie = 0
357 user.set(self.nodeid, **props)
358 self._post_editnode(self.nodeid, changed)
359 # and some feedback for the user
360 message = '%s edited ok'%', '.join(changed)
361 except:
362 s = StringIO.StringIO()
363 traceback.print_exc(None, s)
364 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
365 else:
366 set_cookie = 0
368 # fix the cookie if the password has changed
369 if set_cookie:
370 self.set_cookie(self.user, set_cookie)
372 #
373 # now the display
374 #
375 self.pagehead('User: %s'%node_user, message)
377 # use the template to display the item
378 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
379 item.render(self.nodeid)
380 self.pagefoot()
382 def showfile(self):
383 ''' display a file
384 '''
385 nodeid = self.nodeid
386 cl = self.db.file
387 type = cl.get(nodeid, 'type')
388 if type == 'message/rfc822':
389 type = 'text/plain'
390 self.header(headers={'Content-Type': type})
391 self.write(cl.get(nodeid, 'content'))
393 def _createnode(self):
394 ''' create a node based on the contents of the form
395 '''
396 cl = self.db.classes[self.classname]
397 props, dummy = parsePropsFromForm(self.db, cl, self.form)
398 return cl.create(**props)
400 def _post_editnode(self, nid, changes=None):
401 ''' do the linking and message sending part of the node creation
402 '''
403 cn = self.classname
404 cl = self.db.classes[cn]
405 # link if necessary
406 keys = self.form.keys()
407 for key in keys:
408 if key == ':multilink':
409 value = self.form[key].value
410 if type(value) != type([]): value = [value]
411 for value in value:
412 designator, property = value.split(':')
413 link, nodeid = roundupdb.splitDesignator(designator)
414 link = self.db.classes[link]
415 value = link.get(nodeid, property)
416 value.append(nid)
417 link.set(nodeid, **{property: value})
418 elif key == ':link':
419 value = self.form[key].value
420 if type(value) != type([]): value = [value]
421 for value in value:
422 designator, property = value.split(':')
423 link, nodeid = roundupdb.splitDesignator(designator)
424 link = self.db.classes[link]
425 link.set(nodeid, **{property: nid})
427 # handle file attachments
428 files = []
429 if self.form.has_key('__file'):
430 file = self.form['__file']
431 if file.filename:
432 type = mimetypes.guess_type(file.filename)[0]
433 if not type:
434 type = "application/octet-stream"
435 # create the new file entry
436 files.append(self.db.file.create(type=type, name=file.filename,
437 content=file.file.read()))
439 # generate an edit message
440 # don't bother if there's no messages or nosy list
441 props = cl.getprops()
442 note = None
443 if self.form.has_key('__note'):
444 note = self.form['__note']
445 note = note.value
446 send = len(cl.get(nid, 'nosy', [])) or note
447 if (send and props.has_key('messages') and
448 isinstance(props['messages'], hyperdb.Multilink) and
449 props['messages'].classname == 'msg'):
451 # handle the note
452 if note:
453 if '\n' in note:
454 summary = re.split(r'\n\r?', note)[0]
455 else:
456 summary = note
457 m = ['%s\n'%note]
458 else:
459 summary = 'This %s has been edited through the web.\n'%cn
460 m = [summary]
462 first = 1
463 for name, prop in props.items():
464 if changes is not None and name not in changes: continue
465 if first:
466 m.append('\n-------')
467 first = 0
468 value = cl.get(nid, name, None)
469 if isinstance(prop, hyperdb.Link):
470 link = self.db.classes[prop.classname]
471 key = link.labelprop(default_to_id=1)
472 if value is not None and key:
473 value = link.get(value, key)
474 else:
475 value = '-'
476 elif isinstance(prop, hyperdb.Multilink):
477 if value is None: value = []
478 l = []
479 link = self.db.classes[prop.classname]
480 key = link.labelprop(default_to_id=1)
481 for entry in value:
482 if key:
483 l.append(link.get(entry, key))
484 else:
485 l.append(entry)
486 value = ', '.join(l)
487 m.append('%s: %s'%(name, value))
489 # now create the message
490 content = '\n'.join(m)
491 message_id = self.db.msg.create(author=self.getuid(),
492 recipients=[], date=date.Date('.'), summary=summary,
493 content=content)
494 messages = cl.get(nid, 'messages')
495 messages.append(message_id)
496 props = {'messages': messages, 'files': files}
497 cl.set(nid, **props)
499 def newnode(self, message=None):
500 ''' Add a new node to the database.
502 The form works in two modes: blank form and submission (that is,
503 the submission goes to the same URL). **Eventually this means that
504 the form will have previously entered information in it if
505 submission fails.
507 The new node will be created with the properties specified in the
508 form submission. For multilinks, multiple form entries are handled,
509 as are prop=value,value,value. You can't mix them though.
511 If the new node is to be referenced from somewhere else immediately
512 (ie. the new node is a file that is to be attached to a support
513 issue) then supply one of these arguments in addition to the usual
514 form entries:
515 :link=designator:property
516 :multilink=designator:property
517 ... which means that once the new node is created, the "property"
518 on the node given by "designator" should now reference the new
519 node's id. The node id will be appended to the multilink.
520 '''
521 cn = self.classname
522 cl = self.db.classes[cn]
524 # possibly perform a create
525 keys = self.form.keys()
526 if [i for i in keys if i[0] != ':']:
527 props = {}
528 try:
529 nid = self._createnode()
530 self._post_editnode(nid)
531 # and some nice feedback for the user
532 message = '%s created ok'%cn
533 except:
534 s = StringIO.StringIO()
535 traceback.print_exc(None, s)
536 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
537 self.pagehead('New %s'%self.classname.capitalize(), message)
539 # call the template
540 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
541 self.classname)
542 newitem.render(self.form)
544 self.pagefoot()
545 newissue = newnode
546 newuser = newnode
548 def newfile(self, message=None):
549 ''' Add a new file to the database.
551 This form works very much the same way as newnode - it just has a
552 file upload.
553 '''
554 cn = self.classname
555 cl = self.db.classes[cn]
557 # possibly perform a create
558 keys = self.form.keys()
559 if [i for i in keys if i[0] != ':']:
560 try:
561 file = self.form['content']
562 type = mimetypes.guess_type(file.filename)[0]
563 if not type:
564 type = "application/octet-stream"
565 self._post_editnode(cl.create(content=file.file.read(),
566 type=type, name=file.filename))
567 # and some nice feedback for the user
568 message = '%s created ok'%cn
569 except:
570 s = StringIO.StringIO()
571 traceback.print_exc(None, s)
572 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
574 self.pagehead('New %s'%self.classname.capitalize(), message)
575 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
576 self.classname)
577 newitem.render(self.form)
578 self.pagefoot()
580 def classes(self, message=None):
581 ''' display a list of all the classes in the database
582 '''
583 if self.user == 'admin':
584 self.pagehead('Table of classes', message)
585 classnames = self.db.classes.keys()
586 classnames.sort()
587 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
588 for cn in classnames:
589 cl = self.db.getclass(cn)
590 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
591 for key, value in cl.properties.items():
592 if value is None: value = ''
593 else: value = str(value)
594 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
595 key, cgi.escape(value)))
596 self.write('</table>')
597 self.pagefoot()
598 else:
599 raise Unauthorised
601 def login(self, message=None, newuser_form=None):
602 self.pagehead('Login to roundup', message)
603 self.write('''
604 <table>
605 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
606 <form action="login_action" method=POST>
607 <tr><td align=right>Login name: </td>
608 <td><input name="__login_name"></td></tr>
609 <tr><td align=right>Password: </td>
610 <td><input type="password" name="__login_password"></td></tr>
611 <tr><td></td>
612 <td><input type="submit" value="Log In"></td></tr>
613 </form>
614 ''')
615 if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
616 self.write('</table>')
617 self.pagefoot()
618 return
619 values = {'realname': '', 'organisation': '', 'address': '',
620 'phone': '', 'username': '', 'password': '', 'confirm': ''}
621 if newuser_form is not None:
622 for key in newuser_form.keys():
623 values[key] = newuser_form[key].value
624 self.write('''
625 <p>
626 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
627 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
628 <form action="newuser_action" method=POST>
629 <tr><td align=right><em>Name: </em></td>
630 <td><input name="realname" value="%(realname)s"></td></tr>
631 <tr><td align=right><em>Organisation: </em></td>
632 <td><input name="organisation" value="%(organisation)s"></td></tr>
633 <tr><td align=right>E-Mail Address: </td>
634 <td><input name="address" value="%(address)s"></td></tr>
635 <tr><td align=right><em>Phone: </em></td>
636 <td><input name="phone" value="%(phone)s"></td></tr>
637 <tr><td align=right>Preferred Login name: </td>
638 <td><input name="username" value="%(username)s"></td></tr>
639 <tr><td align=right>Password: </td>
640 <td><input type="password" name="password" value="%(password)s"></td></tr>
641 <tr><td align=right>Password Again: </td>
642 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
643 <tr><td></td>
644 <td><input type="submit" value="Register"></td></tr>
645 </form>
646 </table>
647 '''%values)
648 self.pagefoot()
650 def login_action(self, message=None):
651 if not self.form.has_key('__login_name'):
652 return self.login(message='Username required')
653 self.user = self.form['__login_name'].value
654 if self.form.has_key('__login_password'):
655 password = self.form['__login_password'].value
656 else:
657 password = ''
658 # make sure the user exists
659 try:
660 uid = self.db.user.lookup(self.user)
661 except KeyError:
662 name = self.user
663 self.make_user_anonymous()
664 return self.login(message='No such user "%s"'%name)
666 # and that the password is correct
667 pw = self.db.user.get(uid, 'password')
668 if password != self.db.user.get(uid, 'password'):
669 self.make_user_anonymous()
670 return self.login(message='Incorrect password')
672 self.set_cookie(self.user, password)
673 return self.index()
675 def set_cookie(self, user, password):
676 # construct the cookie
677 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
678 if user[-1] == '=':
679 if user[-2] == '=':
680 user = user[:-2]
681 else:
682 user = user[:-1]
683 expire = Cookie._getdate(86400*365)
684 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
685 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
686 user, expire, path)})
688 def make_user_anonymous(self):
689 # make us anonymous if we can
690 try:
691 self.db.user.lookup('anonymous')
692 self.user = 'anonymous'
693 except KeyError:
694 self.user = None
696 def logout(self, message=None):
697 self.make_user_anonymous()
698 # construct the logout cookie
699 now = Cookie._getdate()
700 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
701 self.header({'Set-Cookie':
702 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
703 path)})
704 return self.login()
706 def newuser_action(self, message=None):
707 ''' create a new user based on the contents of the form and then
708 set the cookie
709 '''
710 # re-open the database as "admin"
711 self.db.close()
712 self.db = self.instance.open('admin')
714 # TODO: pre-check the required fields and username key property
715 cl = self.db.user
716 try:
717 props, dummy = parsePropsFromForm(self.db, cl, self.form)
718 uid = cl.create(**props)
719 except ValueError, message:
720 return self.login(message, newuser_form=self.form)
721 self.user = cl.get(uid, 'username')
722 password = cl.get(uid, 'password')
723 self.set_cookie(self.user, self.form['password'].value)
724 return self.index()
726 def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
727 nre=re.compile(r'new(\w+)')):
729 # determine the uid to use
730 self.db = self.instance.open('admin')
731 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
732 user = 'anonymous'
733 if (cookie.has_key('roundup_user') and
734 cookie['roundup_user'].value != 'deleted'):
735 cookie = cookie['roundup_user'].value
736 if len(cookie)%4:
737 cookie = cookie + '='*(4-len(cookie)%4)
738 try:
739 user, password = binascii.a2b_base64(cookie).split(':')
740 except (TypeError, binascii.Error, binascii.Incomplete):
741 # damaged cookie!
742 user, password = 'anonymous', ''
744 # make sure the user exists
745 try:
746 uid = self.db.user.lookup(user)
747 # now validate the password
748 if password != self.db.user.get(uid, 'password'):
749 user = 'anonymous'
750 except KeyError:
751 user = 'anonymous'
753 # make sure the anonymous user is valid if we're using it
754 if user == 'anonymous':
755 self.make_user_anonymous()
756 else:
757 self.user = user
758 self.db.close()
760 # re-open the database for real, using the user
761 self.db = self.instance.open(self.user)
763 # now figure which function to call
764 path = self.split_path
765 if not path or path[0] in ('', 'index'):
766 action = 'index'
767 else:
768 action = path[0]
770 # Everthing ignores path[1:]
771 # - The file download link generator actually relies on this - it
772 # appends the name of the file to the URL so the download file name
773 # is correct, but doesn't actually use it.
775 # everyone is allowed to try to log in
776 if action == 'login_action':
777 return self.login_action()
779 # allow anonymous people to register
780 if action == 'newuser_action':
781 # if we don't have a login and anonymous people aren't allowed to
782 # register, then spit up the login form
783 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
784 return self.login()
785 return self.newuser_action()
787 # make sure totally anonymous access is OK
788 if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
789 return self.login()
791 # here be the "normal" functionality
792 if action == 'index':
793 return self.index()
794 if action == 'list_classes':
795 return self.classes()
796 if action == 'login':
797 return self.login()
798 if action == 'logout':
799 return self.logout()
800 m = dre.match(action)
801 if m:
802 self.classname = m.group(1)
803 self.nodeid = m.group(2)
804 try:
805 cl = self.db.classes[self.classname]
806 except KeyError:
807 raise NotFound
808 try:
809 cl.get(self.nodeid, 'id')
810 except IndexError:
811 raise NotFound
812 try:
813 func = getattr(self, 'show%s'%self.classname)
814 except AttributeError:
815 raise NotFound
816 return func()
817 m = nre.match(action)
818 if m:
819 self.classname = m.group(1)
820 try:
821 func = getattr(self, 'new%s'%self.classname)
822 except AttributeError:
823 raise NotFound
824 return func()
825 self.classname = action
826 try:
827 self.db.getclass(self.classname)
828 except KeyError:
829 raise NotFound
830 self.list()
832 def __del__(self):
833 self.db.close()
836 class ExtendedClient(Client):
837 '''Includes pages and page heading information that relate to the
838 extended schema.
839 '''
840 showsupport = Client.shownode
841 showtimelog = Client.shownode
842 newsupport = Client.newnode
843 newtimelog = Client.newnode
845 default_index_sort = ['-activity']
846 default_index_group = ['priority']
847 default_index_filter = ['status']
848 default_index_columns = ['activity','status','title','assignedto']
849 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
851 def pagehead(self, title, message=None):
852 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
853 machine = self.env['SERVER_NAME']
854 port = self.env['SERVER_PORT']
855 if port != '80': machine = machine + ':' + port
856 base = urlparse.urlunparse(('http', machine, url, None, None, None))
857 if message is not None:
858 message = '<div class="system-msg">%s</div>'%message
859 else:
860 message = ''
861 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
862 user_name = self.user or ''
863 if self.user == 'admin':
864 admin_links = ' | <a href="list_classes">Class List</a>'
865 else:
866 admin_links = ''
867 if self.user not in (None, 'anonymous'):
868 userid = self.db.user.lookup(self.user)
869 user_info = '''
870 <a href="issue?assignedto=%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> |
871 <a href="support?assignedto=%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> |
872 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
873 '''%(userid, userid, userid)
874 else:
875 user_info = '<a href="login">Login</a>'
876 if self.user is not None:
877 add_links = '''
878 | Add
879 <a href="newissue">Issue</a>,
880 <a href="newsupport">Support</a>,
881 <a href="newuser">User</a>
882 '''
883 else:
884 add_links = ''
885 self.write('''<html><head>
886 <title>%s</title>
887 <style type="text/css">%s</style>
888 </head>
889 <body bgcolor=#ffffff>
890 %s
891 <table width=100%% border=0 cellspacing=0 cellpadding=2>
892 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
893 <td align=right valign=bottom>%s</td></tr>
894 <tr class="location-bar">
895 <td align=left>All
896 <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>,
897 <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>
898 | Unassigned
899 <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>,
900 <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>
901 %s
902 %s</td>
903 <td align=right>%s</td>
904 </table>
905 '''%(title, style, message, title, user_name, add_links, admin_links,
906 user_info))
908 def parsePropsFromForm(db, cl, form, nodeid=0):
909 '''Pull properties for the given class out of the form.
910 '''
911 props = {}
912 changed = []
913 keys = form.keys()
914 num_re = re.compile('^\d+$')
915 for key in keys:
916 if not cl.properties.has_key(key):
917 continue
918 proptype = cl.properties[key]
919 if isinstance(proptype, hyperdb.String):
920 value = form[key].value.strip()
921 elif isinstance(proptype, hyperdb.Password):
922 value = password.Password(form[key].value.strip())
923 elif isinstance(proptype, hyperdb.Date):
924 value = date.Date(form[key].value.strip())
925 elif isinstance(proptype, hyperdb.Interval):
926 value = date.Interval(form[key].value.strip())
927 elif isinstance(proptype, hyperdb.Link):
928 value = form[key].value.strip()
929 # see if it's the "no selection" choice
930 if value == '-1':
931 # don't set this property
932 continue
933 else:
934 # handle key values
935 link = cl.properties[key].classname
936 if not num_re.match(value):
937 try:
938 value = db.classes[link].lookup(value)
939 except KeyError:
940 raise ValueError, 'property "%s": %s not a %s'%(
941 key, value, link)
942 elif isinstance(proptype, hyperdb.Multilink):
943 value = form[key]
944 if type(value) != type([]):
945 value = [i.strip() for i in value.value.split(',')]
946 else:
947 value = [i.value.strip() for i in value]
948 link = cl.properties[key].classname
949 l = []
950 for entry in map(str, value):
951 if not num_re.match(entry):
952 try:
953 entry = db.classes[link].lookup(entry)
954 except KeyError:
955 raise ValueError, \
956 'property "%s": "%s" not an entry of %s'%(key,
957 entry, link.capitalize())
958 l.append(entry)
959 l.sort()
960 value = l
961 props[key] = value
963 # get the old value
964 try:
965 existing = cl.get(nodeid, key)
966 except KeyError:
967 # this might be a new property for which there is no existing
968 # value
969 if not cl.properties.has_key(key): raise
971 # if changed, set it
972 if nodeid and value != existing:
973 changed.append(key)
974 props[key] = value
975 return props, changed
977 #
978 # $Log: not supported by cvs2svn $
979 # Revision 1.57 2001/11/15 10:24:27 richard
980 # handle the case where there is no file attached
981 #
982 # Revision 1.56 2001/11/14 21:35:21 richard
983 # . users may attach files to issues (and support in ext) through the web now
984 #
985 # Revision 1.55 2001/11/07 02:34:06 jhermann
986 # Handling of damaged login cookies
987 #
988 # Revision 1.54 2001/11/07 01:16:12 richard
989 # Remove the '=' padding from cookie value so quoting isn't an issue.
990 #
991 # Revision 1.53 2001/11/06 23:22:05 jhermann
992 # More IE fixes: it does not like quotes around cookie values; in the
993 # hope this does not break anything for other browser; if it does, we
994 # need to check HTTP_USER_AGENT
995 #
996 # Revision 1.52 2001/11/06 23:11:22 jhermann
997 # Fixed debug output in page footer; added expiry date to the login cookie
998 # (expires 1 year in the future) to prevent probs with certain versions
999 # of IE
1000 #
1001 # Revision 1.51 2001/11/06 22:00:34 jhermann
1002 # Get debug level from ROUNDUP_DEBUG env var
1003 #
1004 # Revision 1.50 2001/11/05 23:45:40 richard
1005 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1006 # Also made it present nicer error messages (not tracebacks).
1007 #
1008 # Revision 1.49 2001/11/04 03:07:12 richard
1009 # Fixed various cookie-related bugs:
1010 # . bug #477685 ] base64.decodestring breaks
1011 # . bug #477837 ] lynx does not like the cookie
1012 # . bug #477892 ] Password edit doesn't fix login cookie
1013 # Also closed a security hole - a logged-in user could edit another user's
1014 # details.
1015 #
1016 # Revision 1.48 2001/11/03 01:30:18 richard
1017 # Oops. uses pagefoot now.
1018 #
1019 # Revision 1.47 2001/11/03 01:29:28 richard
1020 # Login page didn't have all close tags.
1021 #
1022 # Revision 1.46 2001/11/03 01:26:55 richard
1023 # possibly fix truncated base64'ed user:pass
1024 #
1025 # Revision 1.45 2001/11/01 22:04:37 richard
1026 # Started work on supporting a pop3-fetching server
1027 # Fixed bugs:
1028 # . bug #477104 ] HTML tag error in roundup-server
1029 # . bug #477107 ] HTTP header problem
1030 #
1031 # Revision 1.44 2001/10/28 23:03:08 richard
1032 # Added more useful header to the classic schema.
1033 #
1034 # Revision 1.43 2001/10/24 00:01:42 richard
1035 # More fixes to lockout logic.
1036 #
1037 # Revision 1.42 2001/10/23 23:56:03 richard
1038 # HTML typo
1039 #
1040 # Revision 1.41 2001/10/23 23:52:35 richard
1041 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1042 #
1043 # Revision 1.40 2001/10/23 23:06:39 richard
1044 # Some cleanup.
1045 #
1046 # Revision 1.39 2001/10/23 01:00:18 richard
1047 # Re-enabled login and registration access after lopping them off via
1048 # disabling access for anonymous users.
1049 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1050 # a couple of bugs while I was there. Probably introduced a couple, but
1051 # things seem to work OK at the moment.
1052 #
1053 # Revision 1.38 2001/10/22 03:25:01 richard
1054 # Added configuration for:
1055 # . anonymous user access and registration (deny/allow)
1056 # . filter "widget" location on index page (top, bottom, both)
1057 # Updated some documentation.
1058 #
1059 # Revision 1.37 2001/10/21 07:26:35 richard
1060 # feature #473127: Filenames. I modified the file.index and htmltemplate
1061 # source so that the filename is used in the link and the creation
1062 # information is displayed.
1063 #
1064 # Revision 1.36 2001/10/21 04:44:50 richard
1065 # bug #473124: UI inconsistency with Link fields.
1066 # This also prompted me to fix a fairly long-standing usability issue -
1067 # that of being able to turn off certain filters.
1068 #
1069 # Revision 1.35 2001/10/21 00:17:54 richard
1070 # CGI interface view customisation section may now be hidden (patch from
1071 # Roch'e Compaan.)
1072 #
1073 # Revision 1.34 2001/10/20 11:58:48 richard
1074 # Catch errors in login - no username or password supplied.
1075 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1076 #
1077 # Revision 1.33 2001/10/17 00:18:41 richard
1078 # Manually constructing cookie headers now.
1079 #
1080 # Revision 1.32 2001/10/16 03:36:21 richard
1081 # CGI interface wasn't handling checkboxes at all.
1082 #
1083 # Revision 1.31 2001/10/14 10:55:00 richard
1084 # Handle empty strings in HTML template Link function
1085 #
1086 # Revision 1.30 2001/10/09 07:38:58 richard
1087 # Pushed the base code for the extended schema CGI interface back into the
1088 # code cgi_client module so that future updates will be less painful.
1089 # Also removed a debugging print statement from cgi_client.
1090 #
1091 # Revision 1.29 2001/10/09 07:25:59 richard
1092 # Added the Password property type. See "pydoc roundup.password" for
1093 # implementation details. Have updated some of the documentation too.
1094 #
1095 # Revision 1.28 2001/10/08 00:34:31 richard
1096 # Change message was stuffing up for multilinks with no key property.
1097 #
1098 # Revision 1.27 2001/10/05 02:23:24 richard
1099 # . roundup-admin create now prompts for property info if none is supplied
1100 # on the command-line.
1101 # . hyperdb Class getprops() method may now return only the mutable
1102 # properties.
1103 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1104 # now support anonymous user access (read-only, unless there's an
1105 # "anonymous" user, in which case write access is permitted). Login
1106 # handling has been moved into cgi_client.Client.main()
1107 # . The "extended" schema is now the default in roundup init.
1108 # . The schemas have had their page headings modified to cope with the new
1109 # login handling. Existing installations should copy the interfaces.py
1110 # file from the roundup lib directory to their instance home.
1111 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1112 # Ping - has been removed.
1113 # . Fixed a whole bunch of places in the CGI interface where we should have
1114 # been returning Not Found instead of throwing an exception.
1115 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1116 # an item now throws an exception.
1117 #
1118 # Revision 1.26 2001/09/12 08:31:42 richard
1119 # handle cases where mime type is not guessable
1120 #
1121 # Revision 1.25 2001/08/29 05:30:49 richard
1122 # change messages weren't being saved when there was no-one on the nosy list.
1123 #
1124 # Revision 1.24 2001/08/29 04:49:39 richard
1125 # didn't clean up fully after debugging :(
1126 #
1127 # Revision 1.23 2001/08/29 04:47:18 richard
1128 # Fixed CGI client change messages so they actually include the properties
1129 # changed (again).
1130 #
1131 # Revision 1.22 2001/08/17 00:08:10 richard
1132 # reverted back to sending messages always regardless of who is doing the web
1133 # edit. change notes weren't being saved. bleah. hackish.
1134 #
1135 # Revision 1.21 2001/08/15 23:43:18 richard
1136 # Fixed some isFooTypes that I missed.
1137 # Refactored some code in the CGI code.
1138 #
1139 # Revision 1.20 2001/08/12 06:32:36 richard
1140 # using isinstance(blah, Foo) now instead of isFooType
1141 #
1142 # Revision 1.19 2001/08/07 00:24:42 richard
1143 # stupid typo
1144 #
1145 # Revision 1.18 2001/08/07 00:15:51 richard
1146 # Added the copyright/license notice to (nearly) all files at request of
1147 # Bizar Software.
1148 #
1149 # Revision 1.17 2001/08/02 06:38:17 richard
1150 # Roundupdb now appends "mailing list" information to its messages which
1151 # include the e-mail address and web interface address. Templates may
1152 # override this in their db classes to include specific information (support
1153 # instructions, etc).
1154 #
1155 # Revision 1.16 2001/08/02 05:55:25 richard
1156 # Web edit messages aren't sent to the person who did the edit any more. No
1157 # message is generated if they are the only person on the nosy list.
1158 #
1159 # Revision 1.15 2001/08/02 00:34:10 richard
1160 # bleah syntax error
1161 #
1162 # Revision 1.14 2001/08/02 00:26:16 richard
1163 # Changed the order of the information in the message generated by web edits.
1164 #
1165 # Revision 1.13 2001/07/30 08:12:17 richard
1166 # Added time logging and file uploading to the templates.
1167 #
1168 # Revision 1.12 2001/07/30 06:26:31 richard
1169 # Added some documentation on how the newblah works.
1170 #
1171 # Revision 1.11 2001/07/30 06:17:45 richard
1172 # Features:
1173 # . Added ability for cgi newblah forms to indicate that the new node
1174 # should be linked somewhere.
1175 # Fixed:
1176 # . Fixed the agument handling for the roundup-admin find command.
1177 # . Fixed handling of summary when no note supplied for newblah. Again.
1178 # . Fixed detection of no form in htmltemplate Field display.
1179 #
1180 # Revision 1.10 2001/07/30 02:37:34 richard
1181 # Temporary measure until we have decent schema migration...
1182 #
1183 # Revision 1.9 2001/07/30 01:25:07 richard
1184 # Default implementation is now "classic" rather than "extended" as one would
1185 # expect.
1186 #
1187 # Revision 1.8 2001/07/29 08:27:40 richard
1188 # Fixed handling of passed-in values in form elements (ie. during a
1189 # drill-down)
1190 #
1191 # Revision 1.7 2001/07/29 07:01:39 richard
1192 # Added vim command to all source so that we don't get no steenkin' tabs :)
1193 #
1194 # Revision 1.6 2001/07/29 04:04:00 richard
1195 # Moved some code around allowing for subclassing to change behaviour.
1196 #
1197 # Revision 1.5 2001/07/28 08:16:52 richard
1198 # New issue form handles lack of note better now.
1199 #
1200 # Revision 1.4 2001/07/28 00:34:34 richard
1201 # Fixed some non-string node ids.
1202 #
1203 # Revision 1.3 2001/07/23 03:56:30 richard
1204 # oops, missed a config removal
1205 #
1206 # Revision 1.2 2001/07/22 12:09:32 richard
1207 # Final commit of Grande Splite
1208 #
1209 # Revision 1.1 2001/07/22 11:58:35 richard
1210 # More Grande Splite
1211 #
1212 #
1213 # vim: set filetype=python ts=4 sw=4 et si