8e80355b082726303b322ff8dfffcac1edfacd0f
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.63 2001-11-26 22:55:56 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
25 import binascii, Cookie, time
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31 pass
33 class NotFound(ValueError):
34 pass
36 class Client:
37 '''
38 A note about login
39 ------------------
41 If the user has no login cookie, then they are anonymous. There
42 are two levels of anonymous use. If there is no 'anonymous' user, there
43 is no login at all and the database is opened in read-only mode. If the
44 'anonymous' user exists, the user is logged in using that user (though
45 there is no cookie). This allows them to modify the database, and all
46 modifications are attributed to the 'anonymous' user.
49 Customisation
50 -------------
51 FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
52 ANONYMOUS_ACCESS - one of 'deny', 'allow'
53 ANONYMOUS_REGISTER - one of 'deny', 'allow'
55 from the roundup class:
56 INSTANCE_NAME - defaults to 'Roundup issue tracker'
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 type(v) is type([]):
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 type(arg) == type([]):
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 type(value) == type([]):
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 cl = self.db.classes[cn]
286 self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
287 'classname': cn, 'instancename': cl.INSTANCE_NAME})
288 if sort is None: sort = self.index_arg(':sort')
289 if group is None: group = self.index_arg(':group')
290 if filter is None: filter = self.index_arg(':filter')
291 if columns is None: columns = self.index_arg(':columns')
292 if filterspec is None: filterspec = self.index_filterspec(filter)
293 if show_customization is None:
294 show_customization = self.customization_widget()
296 index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
297 index.render(filterspec, filter, columns, sort, group,
298 show_customization=show_customization)
299 self.pagefoot()
301 def shownode(self, message=None):
302 ''' display an item
303 '''
304 cn = self.classname
305 cl = self.db.classes[cn]
307 # possibly perform an edit
308 keys = self.form.keys()
309 num_re = re.compile('^\d+$')
310 # don't try to set properties if the user has just logged in
311 if keys and not self.form.has_key('__login_name')::
312 try:
313 props, changed = parsePropsFromForm(self.db, cl, self.form,
314 self.nodeid)
316 # set status to chatting if 'unread' or 'resolved'
317 if 'status' not in changed:
318 try:
319 # determine the id of 'unread','resolved' and 'chatting'
320 unread_id = self.db.status.lookup('unread')
321 resolved_id = self.db.status.lookup('resolved')
322 chatting_id = self.db.status.lookup('chatting')
323 except KeyError:
324 pass
325 else:
326 if (not props.has_key('status') or
327 props['status'] == unread_id or
328 props['status'] == resolved_id):
329 props['status'] = chatting_id
330 changed.append('status')
331 note = None
332 if self.form.has_key('__note'):
333 note = self.form['__note']
334 note = note.value
335 if changed or note:
336 cl.set(self.nodeid, **props)
337 self._post_editnode(self.nodeid, changed)
338 # and some nice feedback for the user
339 message = '%s edited ok'%', '.join(changed)
340 else:
341 message = 'nothing changed'
342 except:
343 s = StringIO.StringIO()
344 traceback.print_exc(None, s)
345 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
347 # now the display
348 id = self.nodeid
349 if cl.getkey():
350 id = cl.get(id, cl.getkey())
351 self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
353 nodeid = self.nodeid
355 # use the template to display the item
356 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
357 item.render(nodeid)
359 self.pagefoot()
360 showissue = shownode
361 showmsg = shownode
363 def showuser(self, message=None):
364 '''Display a user page for editing. Make sure the user is allowed
365 to edit this node, and also check for password changes.
366 '''
367 if self.user == 'anonymous':
368 raise Unauthorised
370 user = self.db.user
372 # get the username of the node being edited
373 node_user = user.get(self.nodeid, 'username')
375 if self.user not in ('admin', node_user):
376 raise Unauthorised
378 #
379 # perform any editing
380 #
381 keys = self.form.keys()
382 num_re = re.compile('^\d+$')
383 if keys:
384 try:
385 props, changed = parsePropsFromForm(self.db, user, self.form,
386 self.nodeid)
387 set_cookie = 0
388 if self.nodeid == self.getuid() and 'password' in changed:
389 password = self.form['password'].value.strip()
390 if password:
391 set_cookie = password
392 else:
393 del props['password']
394 del changed[changed.index('password')]
395 user.set(self.nodeid, **props)
396 self._post_editnode(self.nodeid, changed)
397 # and some feedback for the user
398 message = '%s edited ok'%', '.join(changed)
399 except:
400 s = StringIO.StringIO()
401 traceback.print_exc(None, s)
402 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
403 else:
404 set_cookie = 0
406 # fix the cookie if the password has changed
407 if set_cookie:
408 self.set_cookie(self.user, set_cookie)
410 #
411 # now the display
412 #
413 self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
415 # use the template to display the item
416 item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
417 item.render(self.nodeid)
418 self.pagefoot()
420 def showfile(self):
421 ''' display a file
422 '''
423 nodeid = self.nodeid
424 cl = self.db.file
425 mime_type = cl.get(nodeid, 'type')
426 if mime_type == 'message/rfc822':
427 mime_type = 'text/plain'
428 self.header(headers={'Content-Type': mime_type})
429 self.write(cl.get(nodeid, 'content'))
431 def _createnode(self):
432 ''' create a node based on the contents of the form
433 '''
434 cl = self.db.classes[self.classname]
435 props, dummy = parsePropsFromForm(self.db, cl, self.form)
436 return cl.create(**props)
438 def _post_editnode(self, nid, changes=None):
439 ''' do the linking and message sending part of the node creation
440 '''
441 cn = self.classname
442 cl = self.db.classes[cn]
443 # link if necessary
444 keys = self.form.keys()
445 for key in keys:
446 if key == ':multilink':
447 value = self.form[key].value
448 if type(value) != type([]): value = [value]
449 for value in value:
450 designator, property = value.split(':')
451 link, nodeid = roundupdb.splitDesignator(designator)
452 link = self.db.classes[link]
453 value = link.get(nodeid, property)
454 value.append(nid)
455 link.set(nodeid, **{property: value})
456 elif key == ':link':
457 value = self.form[key].value
458 if type(value) != type([]): value = [value]
459 for value in value:
460 designator, property = value.split(':')
461 link, nodeid = roundupdb.splitDesignator(designator)
462 link = self.db.classes[link]
463 link.set(nodeid, **{property: nid})
465 # handle file attachments
466 files = []
467 if self.form.has_key('__file'):
468 file = self.form['__file']
469 if file.filename:
470 mime_type = mimetypes.guess_type(file.filename)[0]
471 if not mime_type:
472 mime_type = "application/octet-stream"
473 # create the new file entry
474 files.append(self.db.file.create(type=mime_type,
475 name=file.filename, content=file.file.read()))
477 # generate an edit message
478 # don't bother if there's no messages or nosy list
479 props = cl.getprops()
480 note = None
481 if self.form.has_key('__note'):
482 note = self.form['__note']
483 note = note.value
484 send = len(cl.get(nid, 'nosy', [])) or note
485 if (send and props.has_key('messages') and
486 isinstance(props['messages'], hyperdb.Multilink) and
487 props['messages'].classname == 'msg'):
489 # handle the note
490 edit_msg = 'This %s has been edited through the web.\n'%cn
491 if note:
492 if '\n' in note:
493 summary = re.split(r'\n\r?', note)[0]
494 else:
495 summary = note
496 m = [edit_msg + '%s\n'%note]
497 else:
498 m = [edit_msg]
500 first = 1
501 for name, prop in props.items():
502 if changes is not None and name not in changes: continue
503 if first:
504 m.append('\n-------')
505 first = 0
506 value = cl.get(nid, name, None)
507 if isinstance(prop, hyperdb.Link):
508 link = self.db.classes[prop.classname]
509 key = link.labelprop(default_to_id=1)
510 if value is not None and key:
511 value = link.get(value, key)
512 else:
513 value = '-'
514 elif isinstance(prop, hyperdb.Multilink):
515 if value is None: value = []
516 l = []
517 link = self.db.classes[prop.classname]
518 key = link.labelprop(default_to_id=1)
519 for entry in value:
520 if key:
521 l.append(link.get(entry, key))
522 else:
523 l.append(entry)
524 value = ', '.join(l)
525 m.append('%s: %s'%(name, value))
527 # now create the message
528 content = '\n'.join(m)
529 message_id = self.db.msg.create(author=self.getuid(),
530 recipients=[], date=date.Date('.'), summary=summary,
531 content=content, files=files)
532 messages = cl.get(nid, 'messages')
533 messages.append(message_id)
534 props = {'messages': messages, 'files': files}
535 cl.set(nid, **props)
537 def newnode(self, message=None):
538 ''' Add a new node to the database.
540 The form works in two modes: blank form and submission (that is,
541 the submission goes to the same URL). **Eventually this means that
542 the form will have previously entered information in it if
543 submission fails.
545 The new node will be created with the properties specified in the
546 form submission. For multilinks, multiple form entries are handled,
547 as are prop=value,value,value. You can't mix them though.
549 If the new node is to be referenced from somewhere else immediately
550 (ie. the new node is a file that is to be attached to a support
551 issue) then supply one of these arguments in addition to the usual
552 form entries:
553 :link=designator:property
554 :multilink=designator:property
555 ... which means that once the new node is created, the "property"
556 on the node given by "designator" should now reference the new
557 node's id. The node id will be appended to the multilink.
558 '''
559 cn = self.classname
560 cl = self.db.classes[cn]
562 # possibly perform a create
563 keys = self.form.keys()
564 if [i for i in keys if i[0] != ':']:
565 props = {}
566 try:
567 nid = self._createnode()
568 self._post_editnode(nid)
569 # and some nice feedback for the user
570 message = '%s created ok'%cn
571 except:
572 s = StringIO.StringIO()
573 traceback.print_exc(None, s)
574 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
575 self.pagehead('New %s'%self.classname.capitalize(), message)
577 # call the template
578 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
579 self.classname)
580 newitem.render(self.form)
582 self.pagefoot()
583 newissue = newnode
584 newuser = newnode
586 def newfile(self, message=None):
587 ''' Add a new file to the database.
589 This form works very much the same way as newnode - it just has a
590 file upload.
591 '''
592 cn = self.classname
593 cl = self.db.classes[cn]
595 # possibly perform a create
596 keys = self.form.keys()
597 if [i for i in keys if i[0] != ':']:
598 try:
599 file = self.form['content']
600 mime_type = mimetypes.guess_type(file.filename)[0]
601 if not mime_type:
602 mime_type = "application/octet-stream"
603 self._post_editnode(cl.create(content=file.file.read(),
604 type=mime_type, name=file.filename))
605 # and some nice feedback for the user
606 message = '%s created ok'%cn
607 except:
608 s = StringIO.StringIO()
609 traceback.print_exc(None, s)
610 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
612 self.pagehead('New %s'%self.classname.capitalize(), message)
613 newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
614 self.classname)
615 newitem.render(self.form)
616 self.pagefoot()
618 def classes(self, message=None):
619 ''' display a list of all the classes in the database
620 '''
621 if self.user == 'admin':
622 self.pagehead(_('Table of classes'), message)
623 classnames = self.db.classes.keys()
624 classnames.sort()
625 self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
626 for cn in classnames:
627 cl = self.db.getclass(cn)
628 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
629 for key, value in cl.properties.items():
630 if value is None: value = ''
631 else: value = str(value)
632 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
633 key, cgi.escape(value)))
634 self.write('</table>')
635 self.pagefoot()
636 else:
637 raise Unauthorised
639 def login(self, message=None, newuser_form=None, action='index'):
640 self.pagehead(_('Login to roundup'), message)
641 self.write('''
642 <table>
643 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
644 <form action="login_action" method=POST>
645 <input type="hidden" name="__destination_url" value="%s">
646 <tr><td align=right>Login name: </td>
647 <td><input name="__login_name"></td></tr>
648 <tr><td align=right>Password: </td>
649 <td><input type="password" name="__login_password"></td></tr>
650 <tr><td></td>
651 <td><input type="submit" value="Log In"></td></tr>
652 </form>
653 '''%action)
654 if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
655 self.write('</table>')
656 self.pagefoot()
657 return
658 values = {'realname': '', 'organisation': '', 'address': '',
659 'phone': '', 'username': '', 'password': '', 'confirm': ''}
660 if newuser_form is not None:
661 for key in newuser_form.keys():
662 values[key] = newuser_form[key].value
663 self.write('''
664 <p>
665 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
666 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
667 <form action="newuser_action" method=POST>
668 <tr><td align=right><em>Name: </em></td>
669 <td><input name="realname" value="%(realname)s"></td></tr>
670 <tr><td align=right><em>Organisation: </em></td>
671 <td><input name="organisation" value="%(organisation)s"></td></tr>
672 <tr><td align=right>E-Mail Address: </td>
673 <td><input name="address" value="%(address)s"></td></tr>
674 <tr><td align=right><em>Phone: </em></td>
675 <td><input name="phone" value="%(phone)s"></td></tr>
676 <tr><td align=right>Preferred Login name: </td>
677 <td><input name="username" value="%(username)s"></td></tr>
678 <tr><td align=right>Password: </td>
679 <td><input type="password" name="password" value="%(password)s"></td></tr>
680 <tr><td align=right>Password Again: </td>
681 <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
682 <tr><td></td>
683 <td><input type="submit" value="Register"></td></tr>
684 </form>
685 </table>
686 '''%values)
687 self.pagefoot()
689 def login_action(self, message=None):
690 if not self.form.has_key('__login_name'):
691 return self.login(message='Username required')
692 self.user = self.form['__login_name'].value
693 if self.form.has_key('__login_password'):
694 password = self.form['__login_password'].value
695 else:
696 password = ''
697 # make sure the user exists
698 try:
699 uid = self.db.user.lookup(self.user)
700 except KeyError:
701 name = self.user
702 self.make_user_anonymous()
703 return self.login(message=_('No such user "%(name)s"')%locals())
705 # and that the password is correct
706 pw = self.db.user.get(uid, 'password')
707 if password != self.db.user.get(uid, 'password'):
708 self.make_user_anonymous()
709 return self.login(message=_('Incorrect password'))
711 self.set_cookie(self.user, password)
713 def set_cookie(self, user, password):
714 # construct the cookie
715 user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
716 if user[-1] == '=':
717 if user[-2] == '=':
718 user = user[:-2]
719 else:
720 user = user[:-1]
721 expire = Cookie._getdate(86400*365)
722 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
723 self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
724 user, expire, path)})
726 def make_user_anonymous(self):
727 # make us anonymous if we can
728 try:
729 self.db.user.lookup('anonymous')
730 self.user = 'anonymous'
731 except KeyError:
732 self.user = None
734 def logout(self, message=None):
735 self.make_user_anonymous()
736 # construct the logout cookie
737 now = Cookie._getdate()
738 path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
739 self.header({'Set-Cookie':
740 'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
741 path)})
742 return self.login()
744 def newuser_action(self, message=None):
745 ''' create a new user based on the contents of the form and then
746 set the cookie
747 '''
748 # re-open the database as "admin"
749 self.db.close()
750 self.db = self.instance.open('admin')
752 # TODO: pre-check the required fields and username key property
753 cl = self.db.user
754 try:
755 props, dummy = parsePropsFromForm(self.db, cl, self.form)
756 uid = cl.create(**props)
757 except ValueError, message:
758 return self.login(message, newuser_form=self.form)
759 self.user = cl.get(uid, 'username')
760 password = cl.get(uid, 'password')
761 self.set_cookie(self.user, self.form['password'].value)
762 return self.index()
764 def main(self):
765 # determine the uid to use
766 self.db = self.instance.open('admin')
767 cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
768 user = 'anonymous'
769 if (cookie.has_key('roundup_user') and
770 cookie['roundup_user'].value != 'deleted'):
771 cookie = cookie['roundup_user'].value
772 if len(cookie)%4:
773 cookie = cookie + '='*(4-len(cookie)%4)
774 try:
775 user, password = binascii.a2b_base64(cookie).split(':')
776 except (TypeError, binascii.Error, binascii.Incomplete):
777 # damaged cookie!
778 user, password = 'anonymous', ''
780 # make sure the user exists
781 try:
782 uid = self.db.user.lookup(user)
783 # now validate the password
784 if password != self.db.user.get(uid, 'password'):
785 user = 'anonymous'
786 except KeyError:
787 user = 'anonymous'
789 # make sure the anonymous user is valid if we're using it
790 if user == 'anonymous':
791 self.make_user_anonymous()
792 else:
793 self.user = user
794 self.db.close()
796 # re-open the database for real, using the user
797 self.db = self.instance.open(self.user)
799 # now figure which function to call
800 path = self.split_path
802 # default action to index if the path has no information in it
803 if not path or path[0] in ('', 'index'):
804 action = 'index'
805 else:
806 action = path[0]
808 # Everthing ignores path[1:]
809 # - The file download link generator actually relies on this - it
810 # appends the name of the file to the URL so the download file name
811 # is correct, but doesn't actually use it.
813 # everyone is allowed to try to log in
814 if action == 'login_action':
815 # do the login
816 self.login_action()
817 # figure the resulting page
818 action = self.form['__destination_url'].value
819 if not action:
820 action = 'index'
821 return self.do_action(action)
823 # allow anonymous people to register
824 if action == 'newuser_action':
825 # if we don't have a login and anonymous people aren't allowed to
826 # register, then spit up the login form
827 if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
828 if action == 'login':
829 return self.login() # go to the index after login
830 else:
831 return self.login(action=action)
832 # add the user
833 self.newuser_action()
834 # figure the resulting page
835 action = self.form['__destination_url'].value
836 if not action:
837 action = 'index'
838 return self.do_action(action)
840 # no login or registration, make sure totally anonymous access is OK
841 if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
842 if action == 'login':
843 return self.login() # go to the index after login
844 else:
845 return self.login(action=action)
847 # just a regular action
848 return self.do_action(action)
850 def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
851 nre=re.compile(r'new(\w+)')):
852 # here be the "normal" functionality
853 if action == 'index':
854 return self.index()
855 if action == 'list_classes':
856 return self.classes()
857 if action == 'login':
858 return self.login()
859 if action == 'logout':
860 return self.logout()
861 m = dre.match(action)
862 if m:
863 self.classname = m.group(1)
864 self.nodeid = m.group(2)
865 try:
866 cl = self.db.classes[self.classname]
867 except KeyError:
868 raise NotFound
869 try:
870 cl.get(self.nodeid, 'id')
871 except IndexError:
872 raise NotFound
873 try:
874 func = getattr(self, 'show%s'%self.classname)
875 except AttributeError:
876 raise NotFound
877 return func()
878 m = nre.match(action)
879 if m:
880 self.classname = m.group(1)
881 try:
882 func = getattr(self, 'new%s'%self.classname)
883 except AttributeError:
884 raise NotFound
885 return func()
886 self.classname = action
887 try:
888 self.db.getclass(self.classname)
889 except KeyError:
890 raise NotFound
891 return self.list()
893 def __del__(self):
894 self.db.close()
897 class ExtendedClient(Client):
898 '''Includes pages and page heading information that relate to the
899 extended schema.
900 '''
901 showsupport = Client.shownode
902 showtimelog = Client.shownode
903 newsupport = Client.newnode
904 newtimelog = Client.newnode
906 default_index_sort = ['-activity']
907 default_index_group = ['priority']
908 default_index_filter = ['status']
909 default_index_columns = ['activity','status','title','assignedto']
910 default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
912 def pagehead(self, title, message=None):
913 url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
914 machine = self.env['SERVER_NAME']
915 port = self.env['SERVER_PORT']
916 if port != '80': machine = machine + ':' + port
917 base = urlparse.urlunparse(('http', machine, url, None, None, None))
918 if message is not None:
919 message = '<div class="system-msg">%s</div>'%message
920 else:
921 message = ''
922 style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
923 user_name = self.user or ''
924 if self.user == 'admin':
925 admin_links = ' | <a href="list_classes">Class List</a>'
926 else:
927 admin_links = ''
928 if self.user not in (None, 'anonymous'):
929 userid = self.db.user.lookup(self.user)
930 user_info = '''
931 <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> |
932 <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> |
933 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
934 '''%(userid, userid, userid)
935 else:
936 user_info = '<a href="login">Login</a>'
937 if self.user is not None:
938 add_links = '''
939 | Add
940 <a href="newissue">Issue</a>,
941 <a href="newsupport">Support</a>,
942 <a href="newuser">User</a>
943 '''
944 else:
945 add_links = ''
946 self.write('''<html><head>
947 <title>%s</title>
948 <style type="text/css">%s</style>
949 </head>
950 <body bgcolor=#ffffff>
951 %s
952 <table width=100%% border=0 cellspacing=0 cellpadding=2>
953 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
954 <td align=right valign=bottom>%s</td></tr>
955 <tr class="location-bar">
956 <td align=left>All
957 <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>,
958 <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>
959 | Unassigned
960 <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>,
961 <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>
962 %s
963 %s</td>
964 <td align=right>%s</td>
965 </table>
966 '''%(title, style, message, title, user_name, add_links, admin_links,
967 user_info))
969 def parsePropsFromForm(db, cl, form, nodeid=0):
970 '''Pull properties for the given class out of the form.
971 '''
972 props = {}
973 changed = []
974 keys = form.keys()
975 num_re = re.compile('^\d+$')
976 for key in keys:
977 if not cl.properties.has_key(key):
978 continue
979 proptype = cl.properties[key]
980 if isinstance(proptype, hyperdb.String):
981 value = form[key].value.strip()
982 elif isinstance(proptype, hyperdb.Password):
983 value = password.Password(form[key].value.strip())
984 elif isinstance(proptype, hyperdb.Date):
985 value = date.Date(form[key].value.strip())
986 elif isinstance(proptype, hyperdb.Interval):
987 value = date.Interval(form[key].value.strip())
988 elif isinstance(proptype, hyperdb.Link):
989 value = form[key].value.strip()
990 # see if it's the "no selection" choice
991 if value == '-1':
992 # don't set this property
993 continue
994 else:
995 # handle key values
996 link = cl.properties[key].classname
997 if not num_re.match(value):
998 try:
999 value = db.classes[link].lookup(value)
1000 except KeyError:
1001 raise ValueError, 'property "%s": %s not a %s'%(
1002 key, value, link)
1003 elif isinstance(proptype, hyperdb.Multilink):
1004 value = form[key]
1005 if type(value) != type([]):
1006 value = [i.strip() for i in value.value.split(',')]
1007 else:
1008 value = [i.value.strip() for i in value]
1009 link = cl.properties[key].classname
1010 l = []
1011 for entry in map(str, value):
1012 if not num_re.match(entry):
1013 try:
1014 entry = db.classes[link].lookup(entry)
1015 except KeyError:
1016 raise ValueError, \
1017 'property "%s": "%s" not an entry of %s'%(key,
1018 entry, link.capitalize())
1019 l.append(entry)
1020 l.sort()
1021 value = l
1022 props[key] = value
1024 # get the old value
1025 if nodeid:
1026 try:
1027 existing = cl.get(nodeid, key)
1028 except KeyError:
1029 # this might be a new property for which there is no existing
1030 # value
1031 if not cl.properties.has_key(key): raise
1033 # if changed, set it
1034 if nodeid and value != existing:
1035 changed.append(key)
1036 props[key] = value
1037 return props, changed
1039 #
1040 # $Log: not supported by cvs2svn $
1041 # Revision 1.62 2001/11/24 00:45:42 jhermann
1042 # typeof() instead of type(): avoid clash with database field(?) "type"
1043 #
1044 # Fixes this traceback:
1045 #
1046 # Traceback (most recent call last):
1047 # File "roundup\cgi_client.py", line 535, in newnode
1048 # self._post_editnode(nid)
1049 # File "roundup\cgi_client.py", line 415, in _post_editnode
1050 # if type(value) != type([]): value = [value]
1051 # UnboundLocalError: local variable 'type' referenced before assignment
1052 #
1053 # Revision 1.61 2001/11/22 15:46:42 jhermann
1054 # Added module docstrings to all modules.
1055 #
1056 # Revision 1.60 2001/11/21 22:57:28 jhermann
1057 # Added dummy hooks for I18N and some preliminary (test) markup of
1058 # translatable messages
1059 #
1060 # Revision 1.59 2001/11/21 03:21:13 richard
1061 # oops
1062 #
1063 # Revision 1.58 2001/11/21 03:11:28 richard
1064 # Better handling of new properties.
1065 #
1066 # Revision 1.57 2001/11/15 10:24:27 richard
1067 # handle the case where there is no file attached
1068 #
1069 # Revision 1.56 2001/11/14 21:35:21 richard
1070 # . users may attach files to issues (and support in ext) through the web now
1071 #
1072 # Revision 1.55 2001/11/07 02:34:06 jhermann
1073 # Handling of damaged login cookies
1074 #
1075 # Revision 1.54 2001/11/07 01:16:12 richard
1076 # Remove the '=' padding from cookie value so quoting isn't an issue.
1077 #
1078 # Revision 1.53 2001/11/06 23:22:05 jhermann
1079 # More IE fixes: it does not like quotes around cookie values; in the
1080 # hope this does not break anything for other browser; if it does, we
1081 # need to check HTTP_USER_AGENT
1082 #
1083 # Revision 1.52 2001/11/06 23:11:22 jhermann
1084 # Fixed debug output in page footer; added expiry date to the login cookie
1085 # (expires 1 year in the future) to prevent probs with certain versions
1086 # of IE
1087 #
1088 # Revision 1.51 2001/11/06 22:00:34 jhermann
1089 # Get debug level from ROUNDUP_DEBUG env var
1090 #
1091 # Revision 1.50 2001/11/05 23:45:40 richard
1092 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1093 # Also made it present nicer error messages (not tracebacks).
1094 #
1095 # Revision 1.49 2001/11/04 03:07:12 richard
1096 # Fixed various cookie-related bugs:
1097 # . bug #477685 ] base64.decodestring breaks
1098 # . bug #477837 ] lynx does not like the cookie
1099 # . bug #477892 ] Password edit doesn't fix login cookie
1100 # Also closed a security hole - a logged-in user could edit another user's
1101 # details.
1102 #
1103 # Revision 1.48 2001/11/03 01:30:18 richard
1104 # Oops. uses pagefoot now.
1105 #
1106 # Revision 1.47 2001/11/03 01:29:28 richard
1107 # Login page didn't have all close tags.
1108 #
1109 # Revision 1.46 2001/11/03 01:26:55 richard
1110 # possibly fix truncated base64'ed user:pass
1111 #
1112 # Revision 1.45 2001/11/01 22:04:37 richard
1113 # Started work on supporting a pop3-fetching server
1114 # Fixed bugs:
1115 # . bug #477104 ] HTML tag error in roundup-server
1116 # . bug #477107 ] HTTP header problem
1117 #
1118 # Revision 1.44 2001/10/28 23:03:08 richard
1119 # Added more useful header to the classic schema.
1120 #
1121 # Revision 1.43 2001/10/24 00:01:42 richard
1122 # More fixes to lockout logic.
1123 #
1124 # Revision 1.42 2001/10/23 23:56:03 richard
1125 # HTML typo
1126 #
1127 # Revision 1.41 2001/10/23 23:52:35 richard
1128 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1129 #
1130 # Revision 1.40 2001/10/23 23:06:39 richard
1131 # Some cleanup.
1132 #
1133 # Revision 1.39 2001/10/23 01:00:18 richard
1134 # Re-enabled login and registration access after lopping them off via
1135 # disabling access for anonymous users.
1136 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1137 # a couple of bugs while I was there. Probably introduced a couple, but
1138 # things seem to work OK at the moment.
1139 #
1140 # Revision 1.38 2001/10/22 03:25:01 richard
1141 # Added configuration for:
1142 # . anonymous user access and registration (deny/allow)
1143 # . filter "widget" location on index page (top, bottom, both)
1144 # Updated some documentation.
1145 #
1146 # Revision 1.37 2001/10/21 07:26:35 richard
1147 # feature #473127: Filenames. I modified the file.index and htmltemplate
1148 # source so that the filename is used in the link and the creation
1149 # information is displayed.
1150 #
1151 # Revision 1.36 2001/10/21 04:44:50 richard
1152 # bug #473124: UI inconsistency with Link fields.
1153 # This also prompted me to fix a fairly long-standing usability issue -
1154 # that of being able to turn off certain filters.
1155 #
1156 # Revision 1.35 2001/10/21 00:17:54 richard
1157 # CGI interface view customisation section may now be hidden (patch from
1158 # Roch'e Compaan.)
1159 #
1160 # Revision 1.34 2001/10/20 11:58:48 richard
1161 # Catch errors in login - no username or password supplied.
1162 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1163 #
1164 # Revision 1.33 2001/10/17 00:18:41 richard
1165 # Manually constructing cookie headers now.
1166 #
1167 # Revision 1.32 2001/10/16 03:36:21 richard
1168 # CGI interface wasn't handling checkboxes at all.
1169 #
1170 # Revision 1.31 2001/10/14 10:55:00 richard
1171 # Handle empty strings in HTML template Link function
1172 #
1173 # Revision 1.30 2001/10/09 07:38:58 richard
1174 # Pushed the base code for the extended schema CGI interface back into the
1175 # code cgi_client module so that future updates will be less painful.
1176 # Also removed a debugging print statement from cgi_client.
1177 #
1178 # Revision 1.29 2001/10/09 07:25:59 richard
1179 # Added the Password property type. See "pydoc roundup.password" for
1180 # implementation details. Have updated some of the documentation too.
1181 #
1182 # Revision 1.28 2001/10/08 00:34:31 richard
1183 # Change message was stuffing up for multilinks with no key property.
1184 #
1185 # Revision 1.27 2001/10/05 02:23:24 richard
1186 # . roundup-admin create now prompts for property info if none is supplied
1187 # on the command-line.
1188 # . hyperdb Class getprops() method may now return only the mutable
1189 # properties.
1190 # . Login now uses cookies, which makes it a whole lot more flexible. We can
1191 # now support anonymous user access (read-only, unless there's an
1192 # "anonymous" user, in which case write access is permitted). Login
1193 # handling has been moved into cgi_client.Client.main()
1194 # . The "extended" schema is now the default in roundup init.
1195 # . The schemas have had their page headings modified to cope with the new
1196 # login handling. Existing installations should copy the interfaces.py
1197 # file from the roundup lib directory to their instance home.
1198 # . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1199 # Ping - has been removed.
1200 # . Fixed a whole bunch of places in the CGI interface where we should have
1201 # been returning Not Found instead of throwing an exception.
1202 # . Fixed a deviation from the spec: trying to modify the 'id' property of
1203 # an item now throws an exception.
1204 #
1205 # Revision 1.26 2001/09/12 08:31:42 richard
1206 # handle cases where mime type is not guessable
1207 #
1208 # Revision 1.25 2001/08/29 05:30:49 richard
1209 # change messages weren't being saved when there was no-one on the nosy list.
1210 #
1211 # Revision 1.24 2001/08/29 04:49:39 richard
1212 # didn't clean up fully after debugging :(
1213 #
1214 # Revision 1.23 2001/08/29 04:47:18 richard
1215 # Fixed CGI client change messages so they actually include the properties
1216 # changed (again).
1217 #
1218 # Revision 1.22 2001/08/17 00:08:10 richard
1219 # reverted back to sending messages always regardless of who is doing the web
1220 # edit. change notes weren't being saved. bleah. hackish.
1221 #
1222 # Revision 1.21 2001/08/15 23:43:18 richard
1223 # Fixed some isFooTypes that I missed.
1224 # Refactored some code in the CGI code.
1225 #
1226 # Revision 1.20 2001/08/12 06:32:36 richard
1227 # using isinstance(blah, Foo) now instead of isFooType
1228 #
1229 # Revision 1.19 2001/08/07 00:24:42 richard
1230 # stupid typo
1231 #
1232 # Revision 1.18 2001/08/07 00:15:51 richard
1233 # Added the copyright/license notice to (nearly) all files at request of
1234 # Bizar Software.
1235 #
1236 # Revision 1.17 2001/08/02 06:38:17 richard
1237 # Roundupdb now appends "mailing list" information to its messages which
1238 # include the e-mail address and web interface address. Templates may
1239 # override this in their db classes to include specific information (support
1240 # instructions, etc).
1241 #
1242 # Revision 1.16 2001/08/02 05:55:25 richard
1243 # Web edit messages aren't sent to the person who did the edit any more. No
1244 # message is generated if they are the only person on the nosy list.
1245 #
1246 # Revision 1.15 2001/08/02 00:34:10 richard
1247 # bleah syntax error
1248 #
1249 # Revision 1.14 2001/08/02 00:26:16 richard
1250 # Changed the order of the information in the message generated by web edits.
1251 #
1252 # Revision 1.13 2001/07/30 08:12:17 richard
1253 # Added time logging and file uploading to the templates.
1254 #
1255 # Revision 1.12 2001/07/30 06:26:31 richard
1256 # Added some documentation on how the newblah works.
1257 #
1258 # Revision 1.11 2001/07/30 06:17:45 richard
1259 # Features:
1260 # . Added ability for cgi newblah forms to indicate that the new node
1261 # should be linked somewhere.
1262 # Fixed:
1263 # . Fixed the agument handling for the roundup-admin find command.
1264 # . Fixed handling of summary when no note supplied for newblah. Again.
1265 # . Fixed detection of no form in htmltemplate Field display.
1266 #
1267 # Revision 1.10 2001/07/30 02:37:34 richard
1268 # Temporary measure until we have decent schema migration...
1269 #
1270 # Revision 1.9 2001/07/30 01:25:07 richard
1271 # Default implementation is now "classic" rather than "extended" as one would
1272 # expect.
1273 #
1274 # Revision 1.8 2001/07/29 08:27:40 richard
1275 # Fixed handling of passed-in values in form elements (ie. during a
1276 # drill-down)
1277 #
1278 # Revision 1.7 2001/07/29 07:01:39 richard
1279 # Added vim command to all source so that we don't get no steenkin' tabs :)
1280 #
1281 # Revision 1.6 2001/07/29 04:04:00 richard
1282 # Moved some code around allowing for subclassing to change behaviour.
1283 #
1284 # Revision 1.5 2001/07/28 08:16:52 richard
1285 # New issue form handles lack of note better now.
1286 #
1287 # Revision 1.4 2001/07/28 00:34:34 richard
1288 # Fixed some non-string node ids.
1289 #
1290 # Revision 1.3 2001/07/23 03:56:30 richard
1291 # oops, missed a config removal
1292 #
1293 # Revision 1.2 2001/07/22 12:09:32 richard
1294 # Final commit of Grande Splite
1295 #
1296 # Revision 1.1 2001/07/22 11:58:35 richard
1297 # More Grande Splite
1298 #
1299 #
1300 # vim: set filetype=python ts=4 sw=4 et si