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