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.53 2001-11-06 23:22:05 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 expire = Cookie._getdate(86400*365)
667 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
668 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
669 user, expire, path)})
671 def make_user_anonymous(self):
672 # make us anonymous if we can
673 try:
674 self.db.user.lookup('anonymous')
675 self.user = 'anonymous'
676 except KeyError:
677 self.user = None
679 def logout(self, message=None):
680 self.make_user_anonymous()
681 # construct the logout cookie
682 now = Cookie._getdate()
683 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
684 self.header({'Set-Cookie':
685 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
686 path)})
687 return self.login()
689 def newuser_action(self, message=None):
690 ''' create a new user based on the contents of the form and then
691 set the cookie
692 '''
693 # re-open the database as "admin"
694 self.db.close()
695 self.db = self.instance.open('admin')
697 # TODO: pre-check the required fields and username key property
698 cl = self.db.user
699 try:
700 props, dummy = parsePropsFromForm(self.db, cl, self.form)
701 uid = cl.create(**props)
702 except ValueError, message:
703 return self.login(message, newuser_form=self.form)
704 self.user = cl.get(uid, 'username')
705 password = cl.get(uid, 'password')
706 self.set_cookie(self.user, self.form['password'].value)
707 return self.index()
709 def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
710 nre=re.compile(r'new(\w+)')):
712 # determine the uid to use
713 self.db = self.instance.open('admin')
714 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
715 user = 'anonymous'
716 if (cookie.has_key('roundup_user') and
717 cookie['roundup_user'].value != 'deleted'):
718 cookie = cookie['roundup_user'].value
719 user, password = binascii.a2b_base64(cookie).split(':')
720 # make sure the user exists
721 try:
722 uid = self.db.user.lookup(user)
723 # now validate the password
724 if password != self.db.user.get(uid, 'password'):
725 user = 'anonymous'
726 except KeyError:
727 user = 'anonymous'
729 # make sure the anonymous user is valid if we're using it
730 if user == 'anonymous':
731 self.make_user_anonymous()
732 else:
733 self.user = user
734 self.db.close()
736 # re-open the database for real, using the user
737 self.db = self.instance.open(self.user)
739 # now figure which function to call
740 path = self.split_path
741 if not path or path[0] in ('', 'index'):
742 action = 'index'
743 else:
744 action = path[0]
746 # Everthing ignores path[1:]
747 # - The file download link generator actually relies on this - it
748 # appends the name of the file to the URL so the download file name
749 # is correct, but doesn't actually use it.
751 # everyone is allowed to try to log in
752 if action == 'login_action':
753 return self.login_action()
755 # allow anonymous people to register
756 if action == 'newuser_action':
757 # if we don't have a login and anonymous people aren't allowed to
758 # register, then spit up the login form
759 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
760 return self.login()
761 return self.newuser_action()
763 # make sure totally anonymous access is OK
764 if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
765 return self.login()
767 # here be the "normal" functionality
768 if action == 'index':
769 return self.index()
770 if action == 'list_classes':
771 return self.classes()
772 if action == 'login':
773 return self.login()
774 if action == 'logout':
775 return self.logout()
776 m = dre.match(action)
777 if m:
778 self.classname = m.group(1)
779 self.nodeid = m.group(2)
780 try:
781 cl = self.db.classes[self.classname]
782 except KeyError:
783 raise NotFound
784 try:
785 cl.get(self.nodeid, 'id')
786 except IndexError:
787 raise NotFound
788 try:
789 func = getattr(self, 'show%s'%self.classname)
790 except AttributeError:
791 raise NotFound
792 return func()
793 m = nre.match(action)
794 if m:
795 self.classname = m.group(1)
796 try:
797 func = getattr(self, 'new%s'%self.classname)
798 except AttributeError:
799 raise NotFound
800 return func()
801 self.classname = action
802 try:
803 self.db.getclass(self.classname)
804 except KeyError:
805 raise NotFound
806 self.list()
808 def __del__(self):
809 self.db.close()
812 class ExtendedClient(Client):
813 '''Includes pages and page heading information that relate to the
814 extended schema.
815 '''
816 showsupport = Client.shownode
817 showtimelog = Client.shownode
818 newsupport = Client.newnode
819 newtimelog = Client.newnode
821 default_index_sort = ['-activity']
822 default_index_group = ['priority']
823 default_index_filter = ['status']
824 default_index_columns = ['activity','status','title','assignedto']
825 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
827 def pagehead(self, title, message=None):
828 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
829 machine = self.env['SERVER_NAME']
830 port = self.env['SERVER_PORT']
831 if port != '80': machine = machine + ':' + port
832 base = urlparse.urlunparse(('http', machine, url, None, None, None))
833 if message is not None:
834 message = '<div class="system-msg">%s</div>'%message
835 else:
836 message = ''
837 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
838 user_name = self.user or ''
839 if self.user == 'admin':
840 admin_links = ' | <a href="list_classes">Class List</a>'
841 else:
842 admin_links = ''
843 if self.user not in (None, 'anonymous'):
844 userid = self.db.user.lookup(self.user)
845 user_info = '''
846 <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> |
847 <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> |
848 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
849 '''%(userid, userid, userid)
850 else:
851 user_info = '<a href="login">Login</a>'
852 if self.user is not None:
853 add_links = '''
854 | Add
855 <a href="newissue">Issue</a>,
856 <a href="newsupport">Support</a>,
857 <a href="newuser">User</a>
858 '''
859 else:
860 add_links = ''
861 self.write('''<html><head>
862 <title>%s</title>
863 <style type="text/css">%s</style>
864 </head>
865 <body bgcolor=#ffffff>
866 %s
867 <table width=100%% border=0 cellspacing=0 cellpadding=2>
868 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
869 <td align=right valign=bottom>%s</td></tr>
870 <tr class="location-bar">
871 <td align=left>All
872 <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>,
873 <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>
874 | Unassigned
875 <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>,
876 <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>
877 %s
878 %s</td>
879 <td align=right>%s</td>
880 </table>
881 '''%(title, style, message, title, user_name, add_links, admin_links,
882 user_info))
884 def parsePropsFromForm(db, cl, form, nodeid=0):
885 '''Pull properties for the given class out of the form.
886 '''
887 props = {}
888 changed = []
889 keys = form.keys()
890 num_re = re.compile('^\d+$')
891 for key in keys:
892 if not cl.properties.has_key(key):
893 continue
894 proptype = cl.properties[key]
895 if isinstance(proptype, hyperdb.String):
896 value = form[key].value.strip()
897 elif isinstance(proptype, hyperdb.Password):
898 value = password.Password(form[key].value.strip())
899 elif isinstance(proptype, hyperdb.Date):
900 value = date.Date(form[key].value.strip())
901 elif isinstance(proptype, hyperdb.Interval):
902 value = date.Interval(form[key].value.strip())
903 elif isinstance(proptype, hyperdb.Link):
904 value = form[key].value.strip()
905 # see if it's the "no selection" choice
906 if value == '-1':
907 # don't set this property
908 continue
909 else:
910 # handle key values
911 link = cl.properties[key].classname
912 if not num_re.match(value):
913 try:
914 value = db.classes[link].lookup(value)
915 except KeyError:
916 raise ValueError, 'property "%s": %s not a %s'%(
917 key, value, link)
918 elif isinstance(proptype, hyperdb.Multilink):
919 value = form[key]
920 if type(value) != type([]):
921 value = [i.strip() for i in value.value.split(',')]
922 else:
923 value = [i.value.strip() for i in value]
924 link = cl.properties[key].classname
925 l = []
926 for entry in map(str, value):
927 if not num_re.match(entry):
928 try:
929 entry = db.classes[link].lookup(entry)
930 except KeyError:
931 raise ValueError, \
932 'property "%s": "%s" not an entry of %s'%(key,
933 entry, link.capitalize())
934 l.append(entry)
935 l.sort()
936 value = l
937 props[key] = value
938 # if changed, set it
939 if nodeid and value != cl.get(nodeid, key):
940 changed.append(key)
941 props[key] = value
942 return props, changed
944 #
945 # $Log: not supported by cvs2svn $
946 # Revision 1.52 2001/11/06 23:11:22 jhermann
947 # Fixed debug output in page footer; added expiry date to the login cookie
948 # (expires 1 year in the future) to prevent probs with certain versions
949 # of IE
950 #
951 # Revision 1.51 2001/11/06 22:00:34 jhermann
952 # Get debug level from ROUNDUP_DEBUG env var
953 #
954 # Revision 1.50 2001/11/05 23:45:40 richard
955 # Fixed newuser_action so it sets the cookie with the unencrypted password.
956 # Also made it present nicer error messages (not tracebacks).
957 #
958 # Revision 1.49 2001/11/04 03:07:12 richard
959 # Fixed various cookie-related bugs:
960 # . bug #477685 ] base64.decodestring breaks
961 # . bug #477837 ] lynx does not like the cookie
962 # . bug #477892 ] Password edit doesn't fix login cookie
963 # Also closed a security hole - a logged-in user could edit another user's
964 # details.
965 #
966 # Revision 1.48 2001/11/03 01:30:18 richard
967 # Oops. uses pagefoot now.
968 #
969 # Revision 1.47 2001/11/03 01:29:28 richard
970 # Login page didn't have all close tags.
971 #
972 # Revision 1.46 2001/11/03 01:26:55 richard
973 # possibly fix truncated base64'ed user:pass
974 #
975 # Revision 1.45 2001/11/01 22:04:37 richard
976 # Started work on supporting a pop3-fetching server
977 # Fixed bugs:
978 # . bug #477104 ] HTML tag error in roundup-server
979 # . bug #477107 ] HTTP header problem
980 #
981 # Revision 1.44 2001/10/28 23:03:08 richard
982 # Added more useful header to the classic schema.
983 #
984 # Revision 1.43 2001/10/24 00:01:42 richard
985 # More fixes to lockout logic.
986 #
987 # Revision 1.42 2001/10/23 23:56:03 richard
988 # HTML typo
989 #
990 # Revision 1.41 2001/10/23 23:52:35 richard
991 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
992 #
993 # Revision 1.40 2001/10/23 23:06:39 richard
994 # Some cleanup.
995 #
996 # Revision 1.39 2001/10/23 01:00:18 richard
997 # Re-enabled login and registration access after lopping them off via
998 # disabling access for anonymous users.
999 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1000 # a couple of bugs while I was there. Probably introduced a couple, but
1001 # things seem to work OK at the moment.
1002 #
1003 # Revision 1.38 2001/10/22 03:25:01 richard
1004 # Added configuration for:
1005 # . anonymous user access and registration (deny/allow)
1006 # . filter "widget" location on index page (top, bottom, both)
1007 # Updated some documentation.
1008 #
1009 # Revision 1.37 2001/10/21 07:26:35 richard
1010 # feature #473127: Filenames. I modified the file.index and htmltemplate
1011 # source so that the filename is used in the link and the creation
1012 # information is displayed.
1013 #
1014 # Revision 1.36 2001/10/21 04:44:50 richard
1015 # bug #473124: UI inconsistency with Link fields.
1016 # This also prompted me to fix a fairly long-standing usability issue -
1017 # that of being able to turn off certain filters.
1018 #
1019 # Revision 1.35 2001/10/21 00:17:54 richard
1020 # CGI interface view customisation section may now be hidden (patch from
1021 # Roch'e Compaan.)
1022 #
1023 # Revision 1.34 2001/10/20 11:58:48 richard
1024 # Catch errors in login - no username or password supplied.
1025 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1026 #
1027 # Revision 1.33 2001/10/17 00:18:41 richard
1028 # Manually constructing cookie headers now.
1029 #
1030 # Revision 1.32 2001/10/16 03:36:21 richard
1031 # CGI interface wasn't handling checkboxes at all.
1032 #
1033 # Revision 1.31 2001/10/14 10:55:00 richard
1034 # Handle empty strings in HTML template Link function
1035 #
1036 # Revision 1.30 2001/10/09 07:38:58 richard
1037 # Pushed the base code for the extended schema CGI interface back into the
1038 # code cgi_client module so that future updates will be less painful.
1039 # Also removed a debugging print statement from cgi_client.
1040 #
1041 # Revision 1.29 2001/10/09 07:25:59 richard
1042 # Added the Password property type. See "pydoc roundup.password" for
1043 # implementation details. Have updated some of the documentation too.
1044 #
1045 # Revision 1.28 2001/10/08 00:34:31 richard
1046 # Change message was stuffing up for multilinks with no key property.
1047 #
1048 # Revision 1.27 2001/10/05 02:23:24 richard
1049 # . roundup-admin create now prompts for property info if none is supplied
1050 # on the command-line.
1051 # . hyperdb Class getprops() method may now return only the mutable
1052 # properties.
1053 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1054 # now support anonymous user access (read-only, unless there's an
1055 # "anonymous" user, in which case write access is permitted). Login
1056 # handling has been moved into cgi_client.Client.main()
1057 # . The "extended" schema is now the default in roundup init.
1058 # . The schemas have had their page headings modified to cope with the new
1059 # login handling. Existing installations should copy the interfaces.py
1060 # file from the roundup lib directory to their instance home.
1061 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1062 # Ping - has been removed.
1063 # . Fixed a whole bunch of places in the CGI interface where we should have
1064 # been returning Not Found instead of throwing an exception.
1065 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1066 # an item now throws an exception.
1067 #
1068 # Revision 1.26 2001/09/12 08:31:42 richard
1069 # handle cases where mime type is not guessable
1070 #
1071 # Revision 1.25 2001/08/29 05:30:49 richard
1072 # change messages weren't being saved when there was no-one on the nosy list.
1073 #
1074 # Revision 1.24 2001/08/29 04:49:39 richard
1075 # didn't clean up fully after debugging :(
1076 #
1077 # Revision 1.23 2001/08/29 04:47:18 richard
1078 # Fixed CGI client change messages so they actually include the properties
1079 # changed (again).
1080 #
1081 # Revision 1.22 2001/08/17 00:08:10 richard
1082 # reverted back to sending messages always regardless of who is doing the web
1083 # edit. change notes weren't being saved. bleah. hackish.
1084 #
1085 # Revision 1.21 2001/08/15 23:43:18 richard
1086 # Fixed some isFooTypes that I missed.
1087 # Refactored some code in the CGI code.
1088 #
1089 # Revision 1.20 2001/08/12 06:32:36 richard
1090 # using isinstance(blah, Foo) now instead of isFooType
1091 #
1092 # Revision 1.19 2001/08/07 00:24:42 richard
1093 # stupid typo
1094 #
1095 # Revision 1.18 2001/08/07 00:15:51 richard
1096 # Added the copyright/license notice to (nearly) all files at request of
1097 # Bizar Software.
1098 #
1099 # Revision 1.17 2001/08/02 06:38:17 richard
1100 # Roundupdb now appends "mailing list" information to its messages which
1101 # include the e-mail address and web interface address. Templates may
1102 # override this in their db classes to include specific information (support
1103 # instructions, etc).
1104 #
1105 # Revision 1.16 2001/08/02 05:55:25 richard
1106 # Web edit messages aren't sent to the person who did the edit any more. No
1107 # message is generated if they are the only person on the nosy list.
1108 #
1109 # Revision 1.15 2001/08/02 00:34:10 richard
1110 # bleah syntax error
1111 #
1112 # Revision 1.14 2001/08/02 00:26:16 richard
1113 # Changed the order of the information in the message generated by web edits.
1114 #
1115 # Revision 1.13 2001/07/30 08:12:17 richard
1116 # Added time logging and file uploading to the templates.
1117 #
1118 # Revision 1.12 2001/07/30 06:26:31 richard
1119 # Added some documentation on how the newblah works.
1120 #
1121 # Revision 1.11 2001/07/30 06:17:45 richard
1122 # Features:
1123 # . Added ability for cgi newblah forms to indicate that the new node
1124 # should be linked somewhere.
1125 # Fixed:
1126 # . Fixed the agument handling for the roundup-admin find command.
1127 # . Fixed handling of summary when no note supplied for newblah. Again.
1128 # . Fixed detection of no form in htmltemplate Field display.
1129 #
1130 # Revision 1.10 2001/07/30 02:37:34 richard
1131 # Temporary measure until we have decent schema migration...
1132 #
1133 # Revision 1.9 2001/07/30 01:25:07 richard
1134 # Default implementation is now "classic" rather than "extended" as one would
1135 # expect.
1136 #
1137 # Revision 1.8 2001/07/29 08:27:40 richard
1138 # Fixed handling of passed-in values in form elements (ie. during a
1139 # drill-down)
1140 #
1141 # Revision 1.7 2001/07/29 07:01:39 richard
1142 # Added vim command to all source so that we don't get no steenkin' tabs :)
1143 #
1144 # Revision 1.6 2001/07/29 04:04:00 richard
1145 # Moved some code around allowing for subclassing to change behaviour.
1146 #
1147 # Revision 1.5 2001/07/28 08:16:52 richard
1148 # New issue form handles lack of note better now.
1149 #
1150 # Revision 1.4 2001/07/28 00:34:34 richard
1151 # Fixed some non-string node ids.
1152 #
1153 # Revision 1.3 2001/07/23 03:56:30 richard
1154 # oops, missed a config removal
1155 #
1156 # Revision 1.2 2001/07/22 12:09:32 richard
1157 # Final commit of Grande Splite
1158 #
1159 # Revision 1.1 2001/07/22 11:58:35 richard
1160 # More Grande Splite
1161 #
1162 #
1163 # vim: set filetype=python ts=4 sw=4 et si