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