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