Code

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