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