e05aa1a8468dc08215f48c7682ff85b45ec2bbaa
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.56 2001-11-14 21:35:21 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 type = mimetypes.guess_type(file.filename)[0]
432 if not type:
433 type = "application/octet-stream"
434 # create the new file entry
435 files.append(self.db.file.create(type=type, name=file.filename,
436 content=file.file.read()))
438 # generate an edit message
439 # don't bother if there's no messages or nosy list
440 props = cl.getprops()
441 note = None
442 if self.form.has_key('__note'):
443 note = self.form['__note']
444 note = note.value
445 send = len(cl.get(nid, 'nosy', [])) or note
446 if (send and props.has_key('messages') and
447 isinstance(props['messages'], hyperdb.Multilink) and
448 props['messages'].classname == 'msg'):
450 # handle the note
451 if note:
452 if '\n' in note:
453 summary = re.split(r'\n\r?', note)[0]
454 else:
455 summary = note
456 m = ['%s\n'%note]
457 else:
458 summary = 'This %s has been edited through the web.\n'%cn
459 m = [summary]
461 first = 1
462 for name, prop in props.items():
463 if changes is not None and name not in changes: continue
464 if first:
465 m.append('\n-------')
466 first = 0
467 value = cl.get(nid, name, None)
468 if isinstance(prop, hyperdb.Link):
469 link = self.db.classes[prop.classname]
470 key = link.labelprop(default_to_id=1)
471 if value is not None and key:
472 value = link.get(value, key)
473 else:
474 value = '-'
475 elif isinstance(prop, hyperdb.Multilink):
476 if value is None: value = []
477 l = []
478 link = self.db.classes[prop.classname]
479 key = link.labelprop(default_to_id=1)
480 for entry in value:
481 if key:
482 l.append(link.get(entry, key))
483 else:
484 l.append(entry)
485 value = ', '.join(l)
486 m.append('%s: %s'%(name, value))
488 # now create the message
489 content = '\n'.join(m)
490 message_id = self.db.msg.create(author=self.getuid(),
491 recipients=[], date=date.Date('.'), summary=summary,
492 content=content)
493 messages = cl.get(nid, 'messages')
494 messages.append(message_id)
495 props = {'messages': messages, 'files': files}
496 cl.set(nid, **props)
498 def newnode(self, message=None):
499 ''' Add a new node to the database.
501 The form works in two modes: blank form and submission (that is,
502 the submission goes to the same URL). **Eventually this means that
503 the form will have previously entered information in it if
504 submission fails.
506 The new node will be created with the properties specified in the
507 form submission. For multilinks, multiple form entries are handled,
508 as are prop=value,value,value. You can't mix them though.
510 If the new node is to be referenced from somewhere else immediately
511 (ie. the new node is a file that is to be attached to a support
512 issue) then supply one of these arguments in addition to the usual
513 form entries:
514 :link=designator:property
515 :multilink=designator:property
516 ... which means that once the new node is created, the "property"
517 on the node given by "designator" should now reference the new
518 node's id. The node id will be appended to the multilink.
519 '''
520 cn = self.classname
521 cl = self.db.classes[cn]
523 # possibly perform a create
524 keys = self.form.keys()
525 if [i for i in keys if i[0] != ':']:
526 props = {}
527 try:
528 nid = self._createnode()
529 self._post_editnode(nid)
530 # and some nice feedback for the user
531 message = '%s created ok'%cn
532 except:
533 s = StringIO.StringIO()
534 traceback.print_exc(None, s)
535 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
536 self.pagehead('New %s'%self.classname.capitalize(), message)
538 # call the template
539 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
540 self.classname)
541 newitem.render(self.form)
543 self.pagefoot()
544 newissue = newnode
545 newuser = newnode
547 def newfile(self, message=None):
548 ''' Add a new file to the database.
550 This form works very much the same way as newnode - it just has a
551 file upload.
552 '''
553 cn = self.classname
554 cl = self.db.classes[cn]
556 # possibly perform a create
557 keys = self.form.keys()
558 if [i for i in keys if i[0] != ':']:
559 try:
560 file = self.form['content']
561 type = mimetypes.guess_type(file.filename)[0]
562 if not type:
563 type = "application/octet-stream"
564 self._post_editnode(cl.create(content=file.file.read(),
565 type=type, name=file.filename))
566 # and some nice feedback for the user
567 message = '%s created ok'%cn
568 except:
569 s = StringIO.StringIO()
570 traceback.print_exc(None, s)
571 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
573 self.pagehead('New %s'%self.classname.capitalize(), message)
574 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
575 self.classname)
576 newitem.render(self.form)
577 self.pagefoot()
579 def classes(self, message=None):
580 ''' display a list of all the classes in the database
581 '''
582 if self.user == 'admin':
583 self.pagehead('Table of classes', message)
584 classnames = self.db.classes.keys()
585 classnames.sort()
586 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
587 for cn in classnames:
588 cl = self.db.getclass(cn)
589 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
590 for key, value in cl.properties.items():
591 if value is None: value = ''
592 else: value = str(value)
593 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
594 key, cgi.escape(value)))
595 self.write('</table>')
596 self.pagefoot()
597 else:
598 raise Unauthorised
600 def login(self, message=None, newuser_form=None):
601 self.pagehead('Login to roundup', message)
602 self.write('''
603 <table>
604 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
605 <form action="login_action" method=POST>
606 <tr><td align=right>Login name: </td>
607 <td><input name="__login_name"></td></tr>
608 <tr><td align=right>Password: </td>
609 <td><input type="password" name="__login_password"></td></tr>
610 <tr><td></td>
611 <td><input type="submit" value="Log In"></td></tr>
612 </form>
613 ''')
614 if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
615 self.write('</table>')
616 self.pagefoot()
617 return
618 values = {'realname': '', 'organisation': '', 'address': '',
619 'phone': '', 'username': '', 'password': '', 'confirm': ''}
620 if newuser_form is not None:
621 for key in newuser_form.keys():
622 values[key] = newuser_form[key].value
623 self.write('''
624 <p>
625 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
626 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
627 <form action="newuser_action" method=POST>
628 <tr><td align=right><em>Name: </em></td>
629 <td><input name="realname" value="%(realname)s"></td></tr>
630 <tr><td align=right><em>Organisation: </em></td>
631 <td><input name="organisation" value="%(organisation)s"></td></tr>
632 <tr><td align=right>E-Mail Address: </td>
633 <td><input name="address" value="%(address)s"></td></tr>
634 <tr><td align=right><em>Phone: </em></td>
635 <td><input name="phone" value="%(phone)s"></td></tr>
636 <tr><td align=right>Preferred Login name: </td>
637 <td><input name="username" value="%(username)s"></td></tr>
638 <tr><td align=right>Password: </td>
639 <td><input type="password" name="password" value="%(password)s"></td></tr>
640 <tr><td align=right>Password Again: </td>
641 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
642 <tr><td></td>
643 <td><input type="submit" value="Register"></td></tr>
644 </form>
645 </table>
646 '''%values)
647 self.pagefoot()
649 def login_action(self, message=None):
650 if not self.form.has_key('__login_name'):
651 return self.login(message='Username required')
652 self.user = self.form['__login_name'].value
653 if self.form.has_key('__login_password'):
654 password = self.form['__login_password'].value
655 else:
656 password = ''
657 # make sure the user exists
658 try:
659 uid = self.db.user.lookup(self.user)
660 except KeyError:
661 name = self.user
662 self.make_user_anonymous()
663 return self.login(message='No such user "%s"'%name)
665 # and that the password is correct
666 pw = self.db.user.get(uid, 'password')
667 if password != self.db.user.get(uid, 'password'):
668 self.make_user_anonymous()
669 return self.login(message='Incorrect password')
671 self.set_cookie(self.user, password)
672 return self.index()
674 def set_cookie(self, user, password):
675 # construct the cookie
676 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
677 if user[-1] == '=':
678 if user[-2] == '=':
679 user = user[:-2]
680 else:
681 user = user[:-1]
682 expire = Cookie._getdate(86400*365)
683 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
684 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
685 user, expire, path)})
687 def make_user_anonymous(self):
688 # make us anonymous if we can
689 try:
690 self.db.user.lookup('anonymous')
691 self.user = 'anonymous'
692 except KeyError:
693 self.user = None
695 def logout(self, message=None):
696 self.make_user_anonymous()
697 # construct the logout cookie
698 now = Cookie._getdate()
699 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
700 self.header({'Set-Cookie':
701 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
702 path)})
703 return self.login()
705 def newuser_action(self, message=None):
706 ''' create a new user based on the contents of the form and then
707 set the cookie
708 '''
709 # re-open the database as "admin"
710 self.db.close()
711 self.db = self.instance.open('admin')
713 # TODO: pre-check the required fields and username key property
714 cl = self.db.user
715 try:
716 props, dummy = parsePropsFromForm(self.db, cl, self.form)
717 uid = cl.create(**props)
718 except ValueError, message:
719 return self.login(message, newuser_form=self.form)
720 self.user = cl.get(uid, 'username')
721 password = cl.get(uid, 'password')
722 self.set_cookie(self.user, self.form['password'].value)
723 return self.index()
725 def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
726 nre=re.compile(r'new(\w+)')):
728 # determine the uid to use
729 self.db = self.instance.open('admin')
730 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
731 user = 'anonymous'
732 if (cookie.has_key('roundup_user') and
733 cookie['roundup_user'].value != 'deleted'):
734 cookie = cookie['roundup_user'].value
735 if len(cookie)%4:
736 cookie = cookie + '='*(4-len(cookie)%4)
737 try:
738 user, password = binascii.a2b_base64(cookie).split(':')
739 except (TypeError, binascii.Error, binascii.Incomplete):
740 # damaged cookie!
741 user, password = 'anonymous', ''
743 # make sure the user exists
744 try:
745 uid = self.db.user.lookup(user)
746 # now validate the password
747 if password != self.db.user.get(uid, 'password'):
748 user = 'anonymous'
749 except KeyError:
750 user = 'anonymous'
752 # make sure the anonymous user is valid if we're using it
753 if user == 'anonymous':
754 self.make_user_anonymous()
755 else:
756 self.user = user
757 self.db.close()
759 # re-open the database for real, using the user
760 self.db = self.instance.open(self.user)
762 # now figure which function to call
763 path = self.split_path
764 if not path or path[0] in ('', 'index'):
765 action = 'index'
766 else:
767 action = path[0]
769 # Everthing ignores path[1:]
770 # - The file download link generator actually relies on this - it
771 # appends the name of the file to the URL so the download file name
772 # is correct, but doesn't actually use it.
774 # everyone is allowed to try to log in
775 if action == 'login_action':
776 return self.login_action()
778 # allow anonymous people to register
779 if action == 'newuser_action':
780 # if we don't have a login and anonymous people aren't allowed to
781 # register, then spit up the login form
782 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
783 return self.login()
784 return self.newuser_action()
786 # make sure totally anonymous access is OK
787 if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
788 return self.login()
790 # here be the "normal" functionality
791 if action == 'index':
792 return self.index()
793 if action == 'list_classes':
794 return self.classes()
795 if action == 'login':
796 return self.login()
797 if action == 'logout':
798 return self.logout()
799 m = dre.match(action)
800 if m:
801 self.classname = m.group(1)
802 self.nodeid = m.group(2)
803 try:
804 cl = self.db.classes[self.classname]
805 except KeyError:
806 raise NotFound
807 try:
808 cl.get(self.nodeid, 'id')
809 except IndexError:
810 raise NotFound
811 try:
812 func = getattr(self, 'show%s'%self.classname)
813 except AttributeError:
814 raise NotFound
815 return func()
816 m = nre.match(action)
817 if m:
818 self.classname = m.group(1)
819 try:
820 func = getattr(self, 'new%s'%self.classname)
821 except AttributeError:
822 raise NotFound
823 return func()
824 self.classname = action
825 try:
826 self.db.getclass(self.classname)
827 except KeyError:
828 raise NotFound
829 self.list()
831 def __del__(self):
832 self.db.close()
835 class ExtendedClient(Client):
836 '''Includes pages and page heading information that relate to the
837 extended schema.
838 '''
839 showsupport = Client.shownode
840 showtimelog = Client.shownode
841 newsupport = Client.newnode
842 newtimelog = Client.newnode
844 default_index_sort = ['-activity']
845 default_index_group = ['priority']
846 default_index_filter = ['status']
847 default_index_columns = ['activity','status','title','assignedto']
848 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
850 def pagehead(self, title, message=None):
851 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
852 machine = self.env['SERVER_NAME']
853 port = self.env['SERVER_PORT']
854 if port != '80': machine = machine + ':' + port
855 base = urlparse.urlunparse(('http', machine, url, None, None, None))
856 if message is not None:
857 message = '<div class="system-msg">%s</div>'%message
858 else:
859 message = ''
860 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
861 user_name = self.user or ''
862 if self.user == 'admin':
863 admin_links = ' | <a href="list_classes">Class List</a>'
864 else:
865 admin_links = ''
866 if self.user not in (None, 'anonymous'):
867 userid = self.db.user.lookup(self.user)
868 user_info = '''
869 <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> |
870 <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> |
871 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
872 '''%(userid, userid, userid)
873 else:
874 user_info = '<a href="login">Login</a>'
875 if self.user is not None:
876 add_links = '''
877 | Add
878 <a href="newissue">Issue</a>,
879 <a href="newsupport">Support</a>,
880 <a href="newuser">User</a>
881 '''
882 else:
883 add_links = ''
884 self.write('''<html><head>
885 <title>%s</title>
886 <style type="text/css">%s</style>
887 </head>
888 <body bgcolor=#ffffff>
889 %s
890 <table width=100%% border=0 cellspacing=0 cellpadding=2>
891 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
892 <td align=right valign=bottom>%s</td></tr>
893 <tr class="location-bar">
894 <td align=left>All
895 <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>,
896 <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>
897 | Unassigned
898 <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>,
899 <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>
900 %s
901 %s</td>
902 <td align=right>%s</td>
903 </table>
904 '''%(title, style, message, title, user_name, add_links, admin_links,
905 user_info))
907 def parsePropsFromForm(db, cl, form, nodeid=0):
908 '''Pull properties for the given class out of the form.
909 '''
910 props = {}
911 changed = []
912 keys = form.keys()
913 num_re = re.compile('^\d+$')
914 for key in keys:
915 if not cl.properties.has_key(key):
916 continue
917 proptype = cl.properties[key]
918 if isinstance(proptype, hyperdb.String):
919 value = form[key].value.strip()
920 elif isinstance(proptype, hyperdb.Password):
921 value = password.Password(form[key].value.strip())
922 elif isinstance(proptype, hyperdb.Date):
923 value = date.Date(form[key].value.strip())
924 elif isinstance(proptype, hyperdb.Interval):
925 value = date.Interval(form[key].value.strip())
926 elif isinstance(proptype, hyperdb.Link):
927 value = form[key].value.strip()
928 # see if it's the "no selection" choice
929 if value == '-1':
930 # don't set this property
931 continue
932 else:
933 # handle key values
934 link = cl.properties[key].classname
935 if not num_re.match(value):
936 try:
937 value = db.classes[link].lookup(value)
938 except KeyError:
939 raise ValueError, 'property "%s": %s not a %s'%(
940 key, value, link)
941 elif isinstance(proptype, hyperdb.Multilink):
942 value = form[key]
943 if type(value) != type([]):
944 value = [i.strip() for i in value.value.split(',')]
945 else:
946 value = [i.value.strip() for i in value]
947 link = cl.properties[key].classname
948 l = []
949 for entry in map(str, value):
950 if not num_re.match(entry):
951 try:
952 entry = db.classes[link].lookup(entry)
953 except KeyError:
954 raise ValueError, \
955 'property "%s": "%s" not an entry of %s'%(key,
956 entry, link.capitalize())
957 l.append(entry)
958 l.sort()
959 value = l
960 props[key] = value
961 # if changed, set it
962 if nodeid and value != cl.get(nodeid, key):
963 changed.append(key)
964 props[key] = value
965 return props, changed
967 #
968 # $Log: not supported by cvs2svn $
969 # Revision 1.55 2001/11/07 02:34:06 jhermann
970 # Handling of damaged login cookies
971 #
972 # Revision 1.54 2001/11/07 01:16:12 richard
973 # Remove the '=' padding from cookie value so quoting isn't an issue.
974 #
975 # Revision 1.53 2001/11/06 23:22:05 jhermann
976 # More IE fixes: it does not like quotes around cookie values; in the
977 # hope this does not break anything for other browser; if it does, we
978 # need to check HTTP_USER_AGENT
979 #
980 # Revision 1.52 2001/11/06 23:11:22 jhermann
981 # Fixed debug output in page footer; added expiry date to the login cookie
982 # (expires 1 year in the future) to prevent probs with certain versions
983 # of IE
984 #
985 # Revision 1.51 2001/11/06 22:00:34 jhermann
986 # Get debug level from ROUNDUP_DEBUG env var
987 #
988 # Revision 1.50 2001/11/05 23:45:40 richard
989 # Fixed newuser_action so it sets the cookie with the unencrypted password.
990 # Also made it present nicer error messages (not tracebacks).
991 #
992 # Revision 1.49 2001/11/04 03:07:12 richard
993 # Fixed various cookie-related bugs:
994 # . bug #477685 ] base64.decodestring breaks
995 # . bug #477837 ] lynx does not like the cookie
996 # . bug #477892 ] Password edit doesn't fix login cookie
997 # Also closed a security hole - a logged-in user could edit another user's
998 # details.
999 #
1000 # Revision 1.48 2001/11/03 01:30:18 richard
1001 # Oops. uses pagefoot now.
1002 #
1003 # Revision 1.47 2001/11/03 01:29:28 richard
1004 # Login page didn't have all close tags.
1005 #
1006 # Revision 1.46 2001/11/03 01:26:55 richard
1007 # possibly fix truncated base64'ed user:pass
1008 #
1009 # Revision 1.45 2001/11/01 22:04:37 richard
1010 # Started work on supporting a pop3-fetching server
1011 # Fixed bugs:
1012 # . bug #477104 ] HTML tag error in roundup-server
1013 # . bug #477107 ] HTTP header problem
1014 #
1015 # Revision 1.44 2001/10/28 23:03:08 richard
1016 # Added more useful header to the classic schema.
1017 #
1018 # Revision 1.43 2001/10/24 00:01:42 richard
1019 # More fixes to lockout logic.
1020 #
1021 # Revision 1.42 2001/10/23 23:56:03 richard
1022 # HTML typo
1023 #
1024 # Revision 1.41 2001/10/23 23:52:35 richard
1025 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1026 #
1027 # Revision 1.40 2001/10/23 23:06:39 richard
1028 # Some cleanup.
1029 #
1030 # Revision 1.39 2001/10/23 01:00:18 richard
1031 # Re-enabled login and registration access after lopping them off via
1032 # disabling access for anonymous users.
1033 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1034 # a couple of bugs while I was there. Probably introduced a couple, but
1035 # things seem to work OK at the moment.
1036 #
1037 # Revision 1.38 2001/10/22 03:25:01 richard
1038 # Added configuration for:
1039 # . anonymous user access and registration (deny/allow)
1040 # . filter "widget" location on index page (top, bottom, both)
1041 # Updated some documentation.
1042 #
1043 # Revision 1.37 2001/10/21 07:26:35 richard
1044 # feature #473127: Filenames. I modified the file.index and htmltemplate
1045 # source so that the filename is used in the link and the creation
1046 # information is displayed.
1047 #
1048 # Revision 1.36 2001/10/21 04:44:50 richard
1049 # bug #473124: UI inconsistency with Link fields.
1050 # This also prompted me to fix a fairly long-standing usability issue -
1051 # that of being able to turn off certain filters.
1052 #
1053 # Revision 1.35 2001/10/21 00:17:54 richard
1054 # CGI interface view customisation section may now be hidden (patch from
1055 # Roch'e Compaan.)
1056 #
1057 # Revision 1.34 2001/10/20 11:58:48 richard
1058 # Catch errors in login - no username or password supplied.
1059 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1060 #
1061 # Revision 1.33 2001/10/17 00:18:41 richard
1062 # Manually constructing cookie headers now.
1063 #
1064 # Revision 1.32 2001/10/16 03:36:21 richard
1065 # CGI interface wasn't handling checkboxes at all.
1066 #
1067 # Revision 1.31 2001/10/14 10:55:00 richard
1068 # Handle empty strings in HTML template Link function
1069 #
1070 # Revision 1.30 2001/10/09 07:38:58 richard
1071 # Pushed the base code for the extended schema CGI interface back into the
1072 # code cgi_client module so that future updates will be less painful.
1073 # Also removed a debugging print statement from cgi_client.
1074 #
1075 # Revision 1.29 2001/10/09 07:25:59 richard
1076 # Added the Password property type. See "pydoc roundup.password" for
1077 # implementation details. Have updated some of the documentation too.
1078 #
1079 # Revision 1.28 2001/10/08 00:34:31 richard
1080 # Change message was stuffing up for multilinks with no key property.
1081 #
1082 # Revision 1.27 2001/10/05 02:23:24 richard
1083 # . roundup-admin create now prompts for property info if none is supplied
1084 # on the command-line.
1085 # . hyperdb Class getprops() method may now return only the mutable
1086 # properties.
1087 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1088 # now support anonymous user access (read-only, unless there's an
1089 # "anonymous" user, in which case write access is permitted). Login
1090 # handling has been moved into cgi_client.Client.main()
1091 # . The "extended" schema is now the default in roundup init.
1092 # . The schemas have had their page headings modified to cope with the new
1093 # login handling. Existing installations should copy the interfaces.py
1094 # file from the roundup lib directory to their instance home.
1095 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1096 # Ping - has been removed.
1097 # . Fixed a whole bunch of places in the CGI interface where we should have
1098 # been returning Not Found instead of throwing an exception.
1099 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1100 # an item now throws an exception.
1101 #
1102 # Revision 1.26 2001/09/12 08:31:42 richard
1103 # handle cases where mime type is not guessable
1104 #
1105 # Revision 1.25 2001/08/29 05:30:49 richard
1106 # change messages weren't being saved when there was no-one on the nosy list.
1107 #
1108 # Revision 1.24 2001/08/29 04:49:39 richard
1109 # didn't clean up fully after debugging :(
1110 #
1111 # Revision 1.23 2001/08/29 04:47:18 richard
1112 # Fixed CGI client change messages so they actually include the properties
1113 # changed (again).
1114 #
1115 # Revision 1.22 2001/08/17 00:08:10 richard
1116 # reverted back to sending messages always regardless of who is doing the web
1117 # edit. change notes weren't being saved. bleah. hackish.
1118 #
1119 # Revision 1.21 2001/08/15 23:43:18 richard
1120 # Fixed some isFooTypes that I missed.
1121 # Refactored some code in the CGI code.
1122 #
1123 # Revision 1.20 2001/08/12 06:32:36 richard
1124 # using isinstance(blah, Foo) now instead of isFooType
1125 #
1126 # Revision 1.19 2001/08/07 00:24:42 richard
1127 # stupid typo
1128 #
1129 # Revision 1.18 2001/08/07 00:15:51 richard
1130 # Added the copyright/license notice to (nearly) all files at request of
1131 # Bizar Software.
1132 #
1133 # Revision 1.17 2001/08/02 06:38:17 richard
1134 # Roundupdb now appends "mailing list" information to its messages which
1135 # include the e-mail address and web interface address. Templates may
1136 # override this in their db classes to include specific information (support
1137 # instructions, etc).
1138 #
1139 # Revision 1.16 2001/08/02 05:55:25 richard
1140 # Web edit messages aren't sent to the person who did the edit any more. No
1141 # message is generated if they are the only person on the nosy list.
1142 #
1143 # Revision 1.15 2001/08/02 00:34:10 richard
1144 # bleah syntax error
1145 #
1146 # Revision 1.14 2001/08/02 00:26:16 richard
1147 # Changed the order of the information in the message generated by web edits.
1148 #
1149 # Revision 1.13 2001/07/30 08:12:17 richard
1150 # Added time logging and file uploading to the templates.
1151 #
1152 # Revision 1.12 2001/07/30 06:26:31 richard
1153 # Added some documentation on how the newblah works.
1154 #
1155 # Revision 1.11 2001/07/30 06:17:45 richard
1156 # Features:
1157 # . Added ability for cgi newblah forms to indicate that the new node
1158 # should be linked somewhere.
1159 # Fixed:
1160 # . Fixed the agument handling for the roundup-admin find command.
1161 # . Fixed handling of summary when no note supplied for newblah. Again.
1162 # . Fixed detection of no form in htmltemplate Field display.
1163 #
1164 # Revision 1.10 2001/07/30 02:37:34 richard
1165 # Temporary measure until we have decent schema migration...
1166 #
1167 # Revision 1.9 2001/07/30 01:25:07 richard
1168 # Default implementation is now "classic" rather than "extended" as one would
1169 # expect.
1170 #
1171 # Revision 1.8 2001/07/29 08:27:40 richard
1172 # Fixed handling of passed-in values in form elements (ie. during a
1173 # drill-down)
1174 #
1175 # Revision 1.7 2001/07/29 07:01:39 richard
1176 # Added vim command to all source so that we don't get no steenkin' tabs :)
1177 #
1178 # Revision 1.6 2001/07/29 04:04:00 richard
1179 # Moved some code around allowing for subclassing to change behaviour.
1180 #
1181 # Revision 1.5 2001/07/28 08:16:52 richard
1182 # New issue form handles lack of note better now.
1183 #
1184 # Revision 1.4 2001/07/28 00:34:34 richard
1185 # Fixed some non-string node ids.
1186 #
1187 # Revision 1.3 2001/07/23 03:56:30 richard
1188 # oops, missed a config removal
1189 #
1190 # Revision 1.2 2001/07/22 12:09:32 richard
1191 # Final commit of Grande Splite
1192 #
1193 # Revision 1.1 2001/07/22 11:58:35 richard
1194 # More Grande Splite
1195 #
1196 #
1197 # vim: set filetype=python ts=4 sw=4 et si