Code

Modified roundup-mailgw so it can read e-mails from a local mail spool
[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.55 2001-11-07 02:34:06 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         if user[-1] == '=':
667           if user[-2] == '=':
668             user = user[:-2]
669           else:
670             user = user[:-1]
671         expire = Cookie._getdate(86400*365)
672         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
673         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
674             user, expire, path)})
676     def make_user_anonymous(self):
677         # make us anonymous if we can
678         try:
679             self.db.user.lookup('anonymous')
680             self.user = 'anonymous'
681         except KeyError:
682             self.user = None
684     def logout(self, message=None):
685         self.make_user_anonymous()
686         # construct the logout cookie
687         now = Cookie._getdate()
688         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
689         self.header({'Set-Cookie':
690             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
691             path)})
692         return self.login()
694     def newuser_action(self, message=None):
695         ''' create a new user based on the contents of the form and then
696         set the cookie
697         '''
698         # re-open the database as "admin"
699         self.db.close()
700         self.db = self.instance.open('admin')
702         # TODO: pre-check the required fields and username key property
703         cl = self.db.user
704         try:
705             props, dummy = parsePropsFromForm(self.db, cl, self.form)
706             uid = cl.create(**props)
707         except ValueError, message:
708             return self.login(message, newuser_form=self.form)
709         self.user = cl.get(uid, 'username')
710         password = cl.get(uid, 'password')
711         self.set_cookie(self.user, self.form['password'].value)
712         return self.index()
714     def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
715             nre=re.compile(r'new(\w+)')):
717         # determine the uid to use
718         self.db = self.instance.open('admin')
719         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
720         user = 'anonymous'
721         if (cookie.has_key('roundup_user') and
722                 cookie['roundup_user'].value != 'deleted'):
723             cookie = cookie['roundup_user'].value
724             if len(cookie)%4:
725               cookie = cookie + '='*(4-len(cookie)%4)
726             try:
727                 user, password = binascii.a2b_base64(cookie).split(':')
728             except (TypeError, binascii.Error, binascii.Incomplete):
729                 # damaged cookie!
730                 user, password = 'anonymous', ''
732             # make sure the user exists
733             try:
734                 uid = self.db.user.lookup(user)
735                 # now validate the password
736                 if password != self.db.user.get(uid, 'password'):
737                     user = 'anonymous'
738             except KeyError:
739                 user = 'anonymous'
741         # make sure the anonymous user is valid if we're using it
742         if user == 'anonymous':
743             self.make_user_anonymous()
744         else:
745             self.user = user
746         self.db.close()
748         # re-open the database for real, using the user
749         self.db = self.instance.open(self.user)
751         # now figure which function to call
752         path = self.split_path
753         if not path or path[0] in ('', 'index'):
754             action = 'index'
755         else:
756             action = path[0]
758         # Everthing ignores path[1:]
759         #  - The file download link generator actually relies on this - it
760         #    appends the name of the file to the URL so the download file name
761         #    is correct, but doesn't actually use it.
763         # everyone is allowed to try to log in
764         if action == 'login_action':
765             return self.login_action()
767         # allow anonymous people to register
768         if action == 'newuser_action':
769             # if we don't have a login and anonymous people aren't allowed to
770             # register, then spit up the login form
771             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
772                 return self.login()
773             return self.newuser_action()
775         # make sure totally anonymous access is OK
776         if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
777             return self.login()
779         # here be the "normal" functionality
780         if action == 'index':
781             return self.index()
782         if action == 'list_classes':
783             return self.classes()
784         if action == 'login':
785             return self.login()
786         if action == 'logout':
787             return self.logout()
788         m = dre.match(action)
789         if m:
790             self.classname = m.group(1)
791             self.nodeid = m.group(2)
792             try:
793                 cl = self.db.classes[self.classname]
794             except KeyError:
795                 raise NotFound
796             try:
797                 cl.get(self.nodeid, 'id')
798             except IndexError:
799                 raise NotFound
800             try:
801                 func = getattr(self, 'show%s'%self.classname)
802             except AttributeError:
803                 raise NotFound
804             return func()
805         m = nre.match(action)
806         if m:
807             self.classname = m.group(1)
808             try:
809                 func = getattr(self, 'new%s'%self.classname)
810             except AttributeError:
811                 raise NotFound
812             return func()
813         self.classname = action
814         try:
815             self.db.getclass(self.classname)
816         except KeyError:
817             raise NotFound
818         self.list()
820     def __del__(self):
821         self.db.close()
824 class ExtendedClient(Client): 
825     '''Includes pages and page heading information that relate to the
826        extended schema.
827     ''' 
828     showsupport = Client.shownode
829     showtimelog = Client.shownode
830     newsupport = Client.newnode
831     newtimelog = Client.newnode
833     default_index_sort = ['-activity']
834     default_index_group = ['priority']
835     default_index_filter = ['status']
836     default_index_columns = ['activity','status','title','assignedto']
837     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
839     def pagehead(self, title, message=None):
840         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
841         machine = self.env['SERVER_NAME']
842         port = self.env['SERVER_PORT']
843         if port != '80': machine = machine + ':' + port
844         base = urlparse.urlunparse(('http', machine, url, None, None, None))
845         if message is not None:
846             message = '<div class="system-msg">%s</div>'%message
847         else:
848             message = ''
849         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
850         user_name = self.user or ''
851         if self.user == 'admin':
852             admin_links = ' | <a href="list_classes">Class List</a>'
853         else:
854             admin_links = ''
855         if self.user not in (None, 'anonymous'):
856             userid = self.db.user.lookup(self.user)
857             user_info = '''
858 <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> |
859 <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> |
860 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
861 '''%(userid, userid, userid)
862         else:
863             user_info = '<a href="login">Login</a>'
864         if self.user is not None:
865             add_links = '''
866 | Add
867 <a href="newissue">Issue</a>,
868 <a href="newsupport">Support</a>,
869 <a href="newuser">User</a>
870 '''
871         else:
872             add_links = ''
873         self.write('''<html><head>
874 <title>%s</title>
875 <style type="text/css">%s</style>
876 </head>
877 <body bgcolor=#ffffff>
878 %s
879 <table width=100%% border=0 cellspacing=0 cellpadding=2>
880 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
881 <td align=right valign=bottom>%s</td></tr>
882 <tr class="location-bar">
883 <td align=left>All
884 <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>,
885 <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>
886 | Unassigned
887 <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>,
888 <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>
889 %s
890 %s</td>
891 <td align=right>%s</td>
892 </table>
893 '''%(title, style, message, title, user_name, add_links, admin_links,
894     user_info))
896 def parsePropsFromForm(db, cl, form, nodeid=0):
897     '''Pull properties for the given class out of the form.
898     '''
899     props = {}
900     changed = []
901     keys = form.keys()
902     num_re = re.compile('^\d+$')
903     for key in keys:
904         if not cl.properties.has_key(key):
905             continue
906         proptype = cl.properties[key]
907         if isinstance(proptype, hyperdb.String):
908             value = form[key].value.strip()
909         elif isinstance(proptype, hyperdb.Password):
910             value = password.Password(form[key].value.strip())
911         elif isinstance(proptype, hyperdb.Date):
912             value = date.Date(form[key].value.strip())
913         elif isinstance(proptype, hyperdb.Interval):
914             value = date.Interval(form[key].value.strip())
915         elif isinstance(proptype, hyperdb.Link):
916             value = form[key].value.strip()
917             # see if it's the "no selection" choice
918             if value == '-1':
919                 # don't set this property
920                 continue
921             else:
922                 # handle key values
923                 link = cl.properties[key].classname
924                 if not num_re.match(value):
925                     try:
926                         value = db.classes[link].lookup(value)
927                     except KeyError:
928                         raise ValueError, 'property "%s": %s not a %s'%(
929                             key, value, link)
930         elif isinstance(proptype, hyperdb.Multilink):
931             value = form[key]
932             if type(value) != type([]):
933                 value = [i.strip() for i in value.value.split(',')]
934             else:
935                 value = [i.value.strip() for i in value]
936             link = cl.properties[key].classname
937             l = []
938             for entry in map(str, value):
939                 if not num_re.match(entry):
940                     try:
941                         entry = db.classes[link].lookup(entry)
942                     except KeyError:
943                         raise ValueError, \
944                             'property "%s": "%s" not an entry of %s'%(key,
945                             entry, link.capitalize())
946                 l.append(entry)
947             l.sort()
948             value = l
949         props[key] = value
950         # if changed, set it
951         if nodeid and value != cl.get(nodeid, key):
952             changed.append(key)
953             props[key] = value
954     return props, changed
957 # $Log: not supported by cvs2svn $
958 # Revision 1.54  2001/11/07 01:16:12  richard
959 # Remove the '=' padding from cookie value so quoting isn't an issue.
961 # Revision 1.53  2001/11/06 23:22:05  jhermann
962 # More IE fixes: it does not like quotes around cookie values; in the
963 # hope this does not break anything for other browser; if it does, we
964 # need to check HTTP_USER_AGENT
966 # Revision 1.52  2001/11/06 23:11:22  jhermann
967 # Fixed debug output in page footer; added expiry date to the login cookie
968 # (expires 1 year in the future) to prevent probs with certain versions
969 # of IE
971 # Revision 1.51  2001/11/06 22:00:34  jhermann
972 # Get debug level from ROUNDUP_DEBUG env var
974 # Revision 1.50  2001/11/05 23:45:40  richard
975 # Fixed newuser_action so it sets the cookie with the unencrypted password.
976 # Also made it present nicer error messages (not tracebacks).
978 # Revision 1.49  2001/11/04 03:07:12  richard
979 # Fixed various cookie-related bugs:
980 #  . bug #477685 ] base64.decodestring breaks
981 #  . bug #477837 ] lynx does not like the cookie
982 #  . bug #477892 ] Password edit doesn't fix login cookie
983 # Also closed a security hole - a logged-in user could edit another user's
984 # details.
986 # Revision 1.48  2001/11/03 01:30:18  richard
987 # Oops. uses pagefoot now.
989 # Revision 1.47  2001/11/03 01:29:28  richard
990 # Login page didn't have all close tags.
992 # Revision 1.46  2001/11/03 01:26:55  richard
993 # possibly fix truncated base64'ed user:pass
995 # Revision 1.45  2001/11/01 22:04:37  richard
996 # Started work on supporting a pop3-fetching server
997 # Fixed bugs:
998 #  . bug #477104 ] HTML tag error in roundup-server
999 #  . bug #477107 ] HTTP header problem
1001 # Revision 1.44  2001/10/28 23:03:08  richard
1002 # Added more useful header to the classic schema.
1004 # Revision 1.43  2001/10/24 00:01:42  richard
1005 # More fixes to lockout logic.
1007 # Revision 1.42  2001/10/23 23:56:03  richard
1008 # HTML typo
1010 # Revision 1.41  2001/10/23 23:52:35  richard
1011 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1013 # Revision 1.40  2001/10/23 23:06:39  richard
1014 # Some cleanup.
1016 # Revision 1.39  2001/10/23 01:00:18  richard
1017 # Re-enabled login and registration access after lopping them off via
1018 # disabling access for anonymous users.
1019 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1020 # a couple of bugs while I was there. Probably introduced a couple, but
1021 # things seem to work OK at the moment.
1023 # Revision 1.38  2001/10/22 03:25:01  richard
1024 # Added configuration for:
1025 #  . anonymous user access and registration (deny/allow)
1026 #  . filter "widget" location on index page (top, bottom, both)
1027 # Updated some documentation.
1029 # Revision 1.37  2001/10/21 07:26:35  richard
1030 # feature #473127: Filenames. I modified the file.index and htmltemplate
1031 #  source so that the filename is used in the link and the creation
1032 #  information is displayed.
1034 # Revision 1.36  2001/10/21 04:44:50  richard
1035 # bug #473124: UI inconsistency with Link fields.
1036 #    This also prompted me to fix a fairly long-standing usability issue -
1037 #    that of being able to turn off certain filters.
1039 # Revision 1.35  2001/10/21 00:17:54  richard
1040 # CGI interface view customisation section may now be hidden (patch from
1041 #  Roch'e Compaan.)
1043 # Revision 1.34  2001/10/20 11:58:48  richard
1044 # Catch errors in login - no username or password supplied.
1045 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1047 # Revision 1.33  2001/10/17 00:18:41  richard
1048 # Manually constructing cookie headers now.
1050 # Revision 1.32  2001/10/16 03:36:21  richard
1051 # CGI interface wasn't handling checkboxes at all.
1053 # Revision 1.31  2001/10/14 10:55:00  richard
1054 # Handle empty strings in HTML template Link function
1056 # Revision 1.30  2001/10/09 07:38:58  richard
1057 # Pushed the base code for the extended schema CGI interface back into the
1058 # code cgi_client module so that future updates will be less painful.
1059 # Also removed a debugging print statement from cgi_client.
1061 # Revision 1.29  2001/10/09 07:25:59  richard
1062 # Added the Password property type. See "pydoc roundup.password" for
1063 # implementation details. Have updated some of the documentation too.
1065 # Revision 1.28  2001/10/08 00:34:31  richard
1066 # Change message was stuffing up for multilinks with no key property.
1068 # Revision 1.27  2001/10/05 02:23:24  richard
1069 #  . roundup-admin create now prompts for property info if none is supplied
1070 #    on the command-line.
1071 #  . hyperdb Class getprops() method may now return only the mutable
1072 #    properties.
1073 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1074 #    now support anonymous user access (read-only, unless there's an
1075 #    "anonymous" user, in which case write access is permitted). Login
1076 #    handling has been moved into cgi_client.Client.main()
1077 #  . The "extended" schema is now the default in roundup init.
1078 #  . The schemas have had their page headings modified to cope with the new
1079 #    login handling. Existing installations should copy the interfaces.py
1080 #    file from the roundup lib directory to their instance home.
1081 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1082 #    Ping - has been removed.
1083 #  . Fixed a whole bunch of places in the CGI interface where we should have
1084 #    been returning Not Found instead of throwing an exception.
1085 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1086 #    an item now throws an exception.
1088 # Revision 1.26  2001/09/12 08:31:42  richard
1089 # handle cases where mime type is not guessable
1091 # Revision 1.25  2001/08/29 05:30:49  richard
1092 # change messages weren't being saved when there was no-one on the nosy list.
1094 # Revision 1.24  2001/08/29 04:49:39  richard
1095 # didn't clean up fully after debugging :(
1097 # Revision 1.23  2001/08/29 04:47:18  richard
1098 # Fixed CGI client change messages so they actually include the properties
1099 # changed (again).
1101 # Revision 1.22  2001/08/17 00:08:10  richard
1102 # reverted back to sending messages always regardless of who is doing the web
1103 # edit. change notes weren't being saved. bleah. hackish.
1105 # Revision 1.21  2001/08/15 23:43:18  richard
1106 # Fixed some isFooTypes that I missed.
1107 # Refactored some code in the CGI code.
1109 # Revision 1.20  2001/08/12 06:32:36  richard
1110 # using isinstance(blah, Foo) now instead of isFooType
1112 # Revision 1.19  2001/08/07 00:24:42  richard
1113 # stupid typo
1115 # Revision 1.18  2001/08/07 00:15:51  richard
1116 # Added the copyright/license notice to (nearly) all files at request of
1117 # Bizar Software.
1119 # Revision 1.17  2001/08/02 06:38:17  richard
1120 # Roundupdb now appends "mailing list" information to its messages which
1121 # include the e-mail address and web interface address. Templates may
1122 # override this in their db classes to include specific information (support
1123 # instructions, etc).
1125 # Revision 1.16  2001/08/02 05:55:25  richard
1126 # Web edit messages aren't sent to the person who did the edit any more. No
1127 # message is generated if they are the only person on the nosy list.
1129 # Revision 1.15  2001/08/02 00:34:10  richard
1130 # bleah syntax error
1132 # Revision 1.14  2001/08/02 00:26:16  richard
1133 # Changed the order of the information in the message generated by web edits.
1135 # Revision 1.13  2001/07/30 08:12:17  richard
1136 # Added time logging and file uploading to the templates.
1138 # Revision 1.12  2001/07/30 06:26:31  richard
1139 # Added some documentation on how the newblah works.
1141 # Revision 1.11  2001/07/30 06:17:45  richard
1142 # Features:
1143 #  . Added ability for cgi newblah forms to indicate that the new node
1144 #    should be linked somewhere.
1145 # Fixed:
1146 #  . Fixed the agument handling for the roundup-admin find command.
1147 #  . Fixed handling of summary when no note supplied for newblah. Again.
1148 #  . Fixed detection of no form in htmltemplate Field display.
1150 # Revision 1.10  2001/07/30 02:37:34  richard
1151 # Temporary measure until we have decent schema migration...
1153 # Revision 1.9  2001/07/30 01:25:07  richard
1154 # Default implementation is now "classic" rather than "extended" as one would
1155 # expect.
1157 # Revision 1.8  2001/07/29 08:27:40  richard
1158 # Fixed handling of passed-in values in form elements (ie. during a
1159 # drill-down)
1161 # Revision 1.7  2001/07/29 07:01:39  richard
1162 # Added vim command to all source so that we don't get no steenkin' tabs :)
1164 # Revision 1.6  2001/07/29 04:04:00  richard
1165 # Moved some code around allowing for subclassing to change behaviour.
1167 # Revision 1.5  2001/07/28 08:16:52  richard
1168 # New issue form handles lack of note better now.
1170 # Revision 1.4  2001/07/28 00:34:34  richard
1171 # Fixed some non-string node ids.
1173 # Revision 1.3  2001/07/23 03:56:30  richard
1174 # oops, missed a config removal
1176 # Revision 1.2  2001/07/22 12:09:32  richard
1177 # Final commit of Grande Splite
1179 # Revision 1.1  2001/07/22 11:58:35  richard
1180 # More Grande Splite
1183 # vim: set filetype=python ts=4 sw=4 et si