Code

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