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