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