Code

e05aa1a8468dc08215f48c7682ff85b45ec2bbaa
[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.56 2001-11-14 21:35:21 richard 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         # handle file attachments
428         files = []
429         if self.form.has_key('__file'):
430             file = self.form['__file']
431             type = mimetypes.guess_type(file.filename)[0]
432             if not type:
433                 type = "application/octet-stream"
434             # create the new file entry
435             files.append(self.db.file.create(type=type, name=file.filename,
436                 content=file.file.read()))
438         # generate an edit message
439         # don't bother if there's no messages or nosy list 
440         props = cl.getprops()
441         note = None
442         if self.form.has_key('__note'):
443             note = self.form['__note']
444             note = note.value
445         send = len(cl.get(nid, 'nosy', [])) or note
446         if (send and props.has_key('messages') and
447                 isinstance(props['messages'], hyperdb.Multilink) and
448                 props['messages'].classname == 'msg'):
450             # handle the note
451             if note:
452                 if '\n' in note:
453                     summary = re.split(r'\n\r?', note)[0]
454                 else:
455                     summary = note
456                 m = ['%s\n'%note]
457             else:
458                 summary = 'This %s has been edited through the web.\n'%cn
459                 m = [summary]
461             first = 1
462             for name, prop in props.items():
463                 if changes is not None and name not in changes: continue
464                 if first:
465                     m.append('\n-------')
466                     first = 0
467                 value = cl.get(nid, name, None)
468                 if isinstance(prop, hyperdb.Link):
469                     link = self.db.classes[prop.classname]
470                     key = link.labelprop(default_to_id=1)
471                     if value is not None and key:
472                         value = link.get(value, key)
473                     else:
474                         value = '-'
475                 elif isinstance(prop, hyperdb.Multilink):
476                     if value is None: value = []
477                     l = []
478                     link = self.db.classes[prop.classname]
479                     key = link.labelprop(default_to_id=1)
480                     for entry in value:
481                         if key:
482                             l.append(link.get(entry, key))
483                         else:
484                             l.append(entry)
485                     value = ', '.join(l)
486                 m.append('%s: %s'%(name, value))
488             # now create the message
489             content = '\n'.join(m)
490             message_id = self.db.msg.create(author=self.getuid(),
491                 recipients=[], date=date.Date('.'), summary=summary,
492                 content=content)
493             messages = cl.get(nid, 'messages')
494             messages.append(message_id)
495             props = {'messages': messages, 'files': files}
496             cl.set(nid, **props)
498     def newnode(self, message=None):
499         ''' Add a new node to the database.
500         
501         The form works in two modes: blank form and submission (that is,
502         the submission goes to the same URL). **Eventually this means that
503         the form will have previously entered information in it if
504         submission fails.
506         The new node will be created with the properties specified in the
507         form submission. For multilinks, multiple form entries are handled,
508         as are prop=value,value,value. You can't mix them though.
510         If the new node is to be referenced from somewhere else immediately
511         (ie. the new node is a file that is to be attached to a support
512         issue) then supply one of these arguments in addition to the usual
513         form entries:
514             :link=designator:property
515             :multilink=designator:property
516         ... which means that once the new node is created, the "property"
517         on the node given by "designator" should now reference the new
518         node's id. The node id will be appended to the multilink.
519         '''
520         cn = self.classname
521         cl = self.db.classes[cn]
523         # possibly perform a create
524         keys = self.form.keys()
525         if [i for i in keys if i[0] != ':']:
526             props = {}
527             try:
528                 nid = self._createnode()
529                 self._post_editnode(nid)
530                 # and some nice feedback for the user
531                 message = '%s created ok'%cn
532             except:
533                 s = StringIO.StringIO()
534                 traceback.print_exc(None, s)
535                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
536         self.pagehead('New %s'%self.classname.capitalize(), message)
538         # call the template
539         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
540             self.classname)
541         newitem.render(self.form)
543         self.pagefoot()
544     newissue = newnode
545     newuser = newnode
547     def newfile(self, message=None):
548         ''' Add a new file to the database.
549         
550         This form works very much the same way as newnode - it just has a
551         file upload.
552         '''
553         cn = self.classname
554         cl = self.db.classes[cn]
556         # possibly perform a create
557         keys = self.form.keys()
558         if [i for i in keys if i[0] != ':']:
559             try:
560                 file = self.form['content']
561                 type = mimetypes.guess_type(file.filename)[0]
562                 if not type:
563                     type = "application/octet-stream"
564                 self._post_editnode(cl.create(content=file.file.read(),
565                     type=type, name=file.filename))
566                 # and some nice feedback for the user
567                 message = '%s created ok'%cn
568             except:
569                 s = StringIO.StringIO()
570                 traceback.print_exc(None, s)
571                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
573         self.pagehead('New %s'%self.classname.capitalize(), message)
574         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
575             self.classname)
576         newitem.render(self.form)
577         self.pagefoot()
579     def classes(self, message=None):
580         ''' display a list of all the classes in the database
581         '''
582         if self.user == 'admin':
583             self.pagehead('Table of classes', message)
584             classnames = self.db.classes.keys()
585             classnames.sort()
586             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
587             for cn in classnames:
588                 cl = self.db.getclass(cn)
589                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
590                 for key, value in cl.properties.items():
591                     if value is None: value = ''
592                     else: value = str(value)
593                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
594                         key, cgi.escape(value)))
595             self.write('</table>')
596             self.pagefoot()
597         else:
598             raise Unauthorised
600     def login(self, message=None, newuser_form=None):
601         self.pagehead('Login to roundup', message)
602         self.write('''
603 <table>
604 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
605 <form action="login_action" method=POST>
606 <tr><td align=right>Login name: </td>
607     <td><input name="__login_name"></td></tr>
608 <tr><td align=right>Password: </td>
609     <td><input type="password" name="__login_password"></td></tr>
610 <tr><td></td>
611     <td><input type="submit" value="Log In"></td></tr>
612 </form>
613 ''')
614         if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
615             self.write('</table>')
616             self.pagefoot()
617             return
618         values = {'realname': '', 'organisation': '', 'address': '',
619             'phone': '', 'username': '', 'password': '', 'confirm': ''}
620         if newuser_form is not None:
621             for key in newuser_form.keys():
622                 values[key] = newuser_form[key].value
623         self.write('''
624 <p>
625 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
626 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
627 <form action="newuser_action" method=POST>
628 <tr><td align=right><em>Name: </em></td>
629     <td><input name="realname" value="%(realname)s"></td></tr>
630 <tr><td align=right><em>Organisation: </em></td>
631     <td><input name="organisation" value="%(organisation)s"></td></tr>
632 <tr><td align=right>E-Mail Address: </td>
633     <td><input name="address" value="%(address)s"></td></tr>
634 <tr><td align=right><em>Phone: </em></td>
635     <td><input name="phone" value="%(phone)s"></td></tr>
636 <tr><td align=right>Preferred Login name: </td>
637     <td><input name="username" value="%(username)s"></td></tr>
638 <tr><td align=right>Password: </td>
639     <td><input type="password" name="password" value="%(password)s"></td></tr>
640 <tr><td align=right>Password Again: </td>
641     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
642 <tr><td></td>
643     <td><input type="submit" value="Register"></td></tr>
644 </form>
645 </table>
646 '''%values)
647         self.pagefoot()
649     def login_action(self, message=None):
650         if not self.form.has_key('__login_name'):
651             return self.login(message='Username required')
652         self.user = self.form['__login_name'].value
653         if self.form.has_key('__login_password'):
654             password = self.form['__login_password'].value
655         else:
656             password = ''
657         # make sure the user exists
658         try:
659             uid = self.db.user.lookup(self.user)
660         except KeyError:
661             name = self.user
662             self.make_user_anonymous()
663             return self.login(message='No such user "%s"'%name)
665         # and that the password is correct
666         pw = self.db.user.get(uid, 'password')
667         if password != self.db.user.get(uid, 'password'):
668             self.make_user_anonymous()
669             return self.login(message='Incorrect password')
671         self.set_cookie(self.user, password)
672         return self.index()
674     def set_cookie(self, user, password):
675         # construct the cookie
676         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
677         if user[-1] == '=':
678           if user[-2] == '=':
679             user = user[:-2]
680           else:
681             user = user[:-1]
682         expire = Cookie._getdate(86400*365)
683         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
684         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
685             user, expire, path)})
687     def make_user_anonymous(self):
688         # make us anonymous if we can
689         try:
690             self.db.user.lookup('anonymous')
691             self.user = 'anonymous'
692         except KeyError:
693             self.user = None
695     def logout(self, message=None):
696         self.make_user_anonymous()
697         # construct the logout cookie
698         now = Cookie._getdate()
699         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
700         self.header({'Set-Cookie':
701             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
702             path)})
703         return self.login()
705     def newuser_action(self, message=None):
706         ''' create a new user based on the contents of the form and then
707         set the cookie
708         '''
709         # re-open the database as "admin"
710         self.db.close()
711         self.db = self.instance.open('admin')
713         # TODO: pre-check the required fields and username key property
714         cl = self.db.user
715         try:
716             props, dummy = parsePropsFromForm(self.db, cl, self.form)
717             uid = cl.create(**props)
718         except ValueError, message:
719             return self.login(message, newuser_form=self.form)
720         self.user = cl.get(uid, 'username')
721         password = cl.get(uid, 'password')
722         self.set_cookie(self.user, self.form['password'].value)
723         return self.index()
725     def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
726             nre=re.compile(r'new(\w+)')):
728         # determine the uid to use
729         self.db = self.instance.open('admin')
730         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
731         user = 'anonymous'
732         if (cookie.has_key('roundup_user') and
733                 cookie['roundup_user'].value != 'deleted'):
734             cookie = cookie['roundup_user'].value
735             if len(cookie)%4:
736               cookie = cookie + '='*(4-len(cookie)%4)
737             try:
738                 user, password = binascii.a2b_base64(cookie).split(':')
739             except (TypeError, binascii.Error, binascii.Incomplete):
740                 # damaged cookie!
741                 user, password = 'anonymous', ''
743             # make sure the user exists
744             try:
745                 uid = self.db.user.lookup(user)
746                 # now validate the password
747                 if password != self.db.user.get(uid, 'password'):
748                     user = 'anonymous'
749             except KeyError:
750                 user = 'anonymous'
752         # make sure the anonymous user is valid if we're using it
753         if user == 'anonymous':
754             self.make_user_anonymous()
755         else:
756             self.user = user
757         self.db.close()
759         # re-open the database for real, using the user
760         self.db = self.instance.open(self.user)
762         # now figure which function to call
763         path = self.split_path
764         if not path or path[0] in ('', 'index'):
765             action = 'index'
766         else:
767             action = path[0]
769         # Everthing ignores path[1:]
770         #  - The file download link generator actually relies on this - it
771         #    appends the name of the file to the URL so the download file name
772         #    is correct, but doesn't actually use it.
774         # everyone is allowed to try to log in
775         if action == 'login_action':
776             return self.login_action()
778         # allow anonymous people to register
779         if action == 'newuser_action':
780             # if we don't have a login and anonymous people aren't allowed to
781             # register, then spit up the login form
782             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
783                 return self.login()
784             return self.newuser_action()
786         # make sure totally anonymous access is OK
787         if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
788             return self.login()
790         # here be the "normal" functionality
791         if action == 'index':
792             return self.index()
793         if action == 'list_classes':
794             return self.classes()
795         if action == 'login':
796             return self.login()
797         if action == 'logout':
798             return self.logout()
799         m = dre.match(action)
800         if m:
801             self.classname = m.group(1)
802             self.nodeid = m.group(2)
803             try:
804                 cl = self.db.classes[self.classname]
805             except KeyError:
806                 raise NotFound
807             try:
808                 cl.get(self.nodeid, 'id')
809             except IndexError:
810                 raise NotFound
811             try:
812                 func = getattr(self, 'show%s'%self.classname)
813             except AttributeError:
814                 raise NotFound
815             return func()
816         m = nre.match(action)
817         if m:
818             self.classname = m.group(1)
819             try:
820                 func = getattr(self, 'new%s'%self.classname)
821             except AttributeError:
822                 raise NotFound
823             return func()
824         self.classname = action
825         try:
826             self.db.getclass(self.classname)
827         except KeyError:
828             raise NotFound
829         self.list()
831     def __del__(self):
832         self.db.close()
835 class ExtendedClient(Client): 
836     '''Includes pages and page heading information that relate to the
837        extended schema.
838     ''' 
839     showsupport = Client.shownode
840     showtimelog = Client.shownode
841     newsupport = Client.newnode
842     newtimelog = Client.newnode
844     default_index_sort = ['-activity']
845     default_index_group = ['priority']
846     default_index_filter = ['status']
847     default_index_columns = ['activity','status','title','assignedto']
848     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
850     def pagehead(self, title, message=None):
851         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
852         machine = self.env['SERVER_NAME']
853         port = self.env['SERVER_PORT']
854         if port != '80': machine = machine + ':' + port
855         base = urlparse.urlunparse(('http', machine, url, None, None, None))
856         if message is not None:
857             message = '<div class="system-msg">%s</div>'%message
858         else:
859             message = ''
860         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
861         user_name = self.user or ''
862         if self.user == 'admin':
863             admin_links = ' | <a href="list_classes">Class List</a>'
864         else:
865             admin_links = ''
866         if self.user not in (None, 'anonymous'):
867             userid = self.db.user.lookup(self.user)
868             user_info = '''
869 <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> |
870 <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> |
871 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
872 '''%(userid, userid, userid)
873         else:
874             user_info = '<a href="login">Login</a>'
875         if self.user is not None:
876             add_links = '''
877 | Add
878 <a href="newissue">Issue</a>,
879 <a href="newsupport">Support</a>,
880 <a href="newuser">User</a>
881 '''
882         else:
883             add_links = ''
884         self.write('''<html><head>
885 <title>%s</title>
886 <style type="text/css">%s</style>
887 </head>
888 <body bgcolor=#ffffff>
889 %s
890 <table width=100%% border=0 cellspacing=0 cellpadding=2>
891 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
892 <td align=right valign=bottom>%s</td></tr>
893 <tr class="location-bar">
894 <td align=left>All
895 <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>,
896 <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>
897 | Unassigned
898 <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>,
899 <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>
900 %s
901 %s</td>
902 <td align=right>%s</td>
903 </table>
904 '''%(title, style, message, title, user_name, add_links, admin_links,
905     user_info))
907 def parsePropsFromForm(db, cl, form, nodeid=0):
908     '''Pull properties for the given class out of the form.
909     '''
910     props = {}
911     changed = []
912     keys = form.keys()
913     num_re = re.compile('^\d+$')
914     for key in keys:
915         if not cl.properties.has_key(key):
916             continue
917         proptype = cl.properties[key]
918         if isinstance(proptype, hyperdb.String):
919             value = form[key].value.strip()
920         elif isinstance(proptype, hyperdb.Password):
921             value = password.Password(form[key].value.strip())
922         elif isinstance(proptype, hyperdb.Date):
923             value = date.Date(form[key].value.strip())
924         elif isinstance(proptype, hyperdb.Interval):
925             value = date.Interval(form[key].value.strip())
926         elif isinstance(proptype, hyperdb.Link):
927             value = form[key].value.strip()
928             # see if it's the "no selection" choice
929             if value == '-1':
930                 # don't set this property
931                 continue
932             else:
933                 # handle key values
934                 link = cl.properties[key].classname
935                 if not num_re.match(value):
936                     try:
937                         value = db.classes[link].lookup(value)
938                     except KeyError:
939                         raise ValueError, 'property "%s": %s not a %s'%(
940                             key, value, link)
941         elif isinstance(proptype, hyperdb.Multilink):
942             value = form[key]
943             if type(value) != type([]):
944                 value = [i.strip() for i in value.value.split(',')]
945             else:
946                 value = [i.value.strip() for i in value]
947             link = cl.properties[key].classname
948             l = []
949             for entry in map(str, value):
950                 if not num_re.match(entry):
951                     try:
952                         entry = db.classes[link].lookup(entry)
953                     except KeyError:
954                         raise ValueError, \
955                             'property "%s": "%s" not an entry of %s'%(key,
956                             entry, link.capitalize())
957                 l.append(entry)
958             l.sort()
959             value = l
960         props[key] = value
961         # if changed, set it
962         if nodeid and value != cl.get(nodeid, key):
963             changed.append(key)
964             props[key] = value
965     return props, changed
968 # $Log: not supported by cvs2svn $
969 # Revision 1.55  2001/11/07 02:34:06  jhermann
970 # Handling of damaged login cookies
972 # Revision 1.54  2001/11/07 01:16:12  richard
973 # Remove the '=' padding from cookie value so quoting isn't an issue.
975 # Revision 1.53  2001/11/06 23:22:05  jhermann
976 # More IE fixes: it does not like quotes around cookie values; in the
977 # hope this does not break anything for other browser; if it does, we
978 # need to check HTTP_USER_AGENT
980 # Revision 1.52  2001/11/06 23:11:22  jhermann
981 # Fixed debug output in page footer; added expiry date to the login cookie
982 # (expires 1 year in the future) to prevent probs with certain versions
983 # of IE
985 # Revision 1.51  2001/11/06 22:00:34  jhermann
986 # Get debug level from ROUNDUP_DEBUG env var
988 # Revision 1.50  2001/11/05 23:45:40  richard
989 # Fixed newuser_action so it sets the cookie with the unencrypted password.
990 # Also made it present nicer error messages (not tracebacks).
992 # Revision 1.49  2001/11/04 03:07:12  richard
993 # Fixed various cookie-related bugs:
994 #  . bug #477685 ] base64.decodestring breaks
995 #  . bug #477837 ] lynx does not like the cookie
996 #  . bug #477892 ] Password edit doesn't fix login cookie
997 # Also closed a security hole - a logged-in user could edit another user's
998 # details.
1000 # Revision 1.48  2001/11/03 01:30:18  richard
1001 # Oops. uses pagefoot now.
1003 # Revision 1.47  2001/11/03 01:29:28  richard
1004 # Login page didn't have all close tags.
1006 # Revision 1.46  2001/11/03 01:26:55  richard
1007 # possibly fix truncated base64'ed user:pass
1009 # Revision 1.45  2001/11/01 22:04:37  richard
1010 # Started work on supporting a pop3-fetching server
1011 # Fixed bugs:
1012 #  . bug #477104 ] HTML tag error in roundup-server
1013 #  . bug #477107 ] HTTP header problem
1015 # Revision 1.44  2001/10/28 23:03:08  richard
1016 # Added more useful header to the classic schema.
1018 # Revision 1.43  2001/10/24 00:01:42  richard
1019 # More fixes to lockout logic.
1021 # Revision 1.42  2001/10/23 23:56:03  richard
1022 # HTML typo
1024 # Revision 1.41  2001/10/23 23:52:35  richard
1025 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1027 # Revision 1.40  2001/10/23 23:06:39  richard
1028 # Some cleanup.
1030 # Revision 1.39  2001/10/23 01:00:18  richard
1031 # Re-enabled login and registration access after lopping them off via
1032 # disabling access for anonymous users.
1033 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1034 # a couple of bugs while I was there. Probably introduced a couple, but
1035 # things seem to work OK at the moment.
1037 # Revision 1.38  2001/10/22 03:25:01  richard
1038 # Added configuration for:
1039 #  . anonymous user access and registration (deny/allow)
1040 #  . filter "widget" location on index page (top, bottom, both)
1041 # Updated some documentation.
1043 # Revision 1.37  2001/10/21 07:26:35  richard
1044 # feature #473127: Filenames. I modified the file.index and htmltemplate
1045 #  source so that the filename is used in the link and the creation
1046 #  information is displayed.
1048 # Revision 1.36  2001/10/21 04:44:50  richard
1049 # bug #473124: UI inconsistency with Link fields.
1050 #    This also prompted me to fix a fairly long-standing usability issue -
1051 #    that of being able to turn off certain filters.
1053 # Revision 1.35  2001/10/21 00:17:54  richard
1054 # CGI interface view customisation section may now be hidden (patch from
1055 #  Roch'e Compaan.)
1057 # Revision 1.34  2001/10/20 11:58:48  richard
1058 # Catch errors in login - no username or password supplied.
1059 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1061 # Revision 1.33  2001/10/17 00:18:41  richard
1062 # Manually constructing cookie headers now.
1064 # Revision 1.32  2001/10/16 03:36:21  richard
1065 # CGI interface wasn't handling checkboxes at all.
1067 # Revision 1.31  2001/10/14 10:55:00  richard
1068 # Handle empty strings in HTML template Link function
1070 # Revision 1.30  2001/10/09 07:38:58  richard
1071 # Pushed the base code for the extended schema CGI interface back into the
1072 # code cgi_client module so that future updates will be less painful.
1073 # Also removed a debugging print statement from cgi_client.
1075 # Revision 1.29  2001/10/09 07:25:59  richard
1076 # Added the Password property type. See "pydoc roundup.password" for
1077 # implementation details. Have updated some of the documentation too.
1079 # Revision 1.28  2001/10/08 00:34:31  richard
1080 # Change message was stuffing up for multilinks with no key property.
1082 # Revision 1.27  2001/10/05 02:23:24  richard
1083 #  . roundup-admin create now prompts for property info if none is supplied
1084 #    on the command-line.
1085 #  . hyperdb Class getprops() method may now return only the mutable
1086 #    properties.
1087 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1088 #    now support anonymous user access (read-only, unless there's an
1089 #    "anonymous" user, in which case write access is permitted). Login
1090 #    handling has been moved into cgi_client.Client.main()
1091 #  . The "extended" schema is now the default in roundup init.
1092 #  . The schemas have had their page headings modified to cope with the new
1093 #    login handling. Existing installations should copy the interfaces.py
1094 #    file from the roundup lib directory to their instance home.
1095 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1096 #    Ping - has been removed.
1097 #  . Fixed a whole bunch of places in the CGI interface where we should have
1098 #    been returning Not Found instead of throwing an exception.
1099 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1100 #    an item now throws an exception.
1102 # Revision 1.26  2001/09/12 08:31:42  richard
1103 # handle cases where mime type is not guessable
1105 # Revision 1.25  2001/08/29 05:30:49  richard
1106 # change messages weren't being saved when there was no-one on the nosy list.
1108 # Revision 1.24  2001/08/29 04:49:39  richard
1109 # didn't clean up fully after debugging :(
1111 # Revision 1.23  2001/08/29 04:47:18  richard
1112 # Fixed CGI client change messages so they actually include the properties
1113 # changed (again).
1115 # Revision 1.22  2001/08/17 00:08:10  richard
1116 # reverted back to sending messages always regardless of who is doing the web
1117 # edit. change notes weren't being saved. bleah. hackish.
1119 # Revision 1.21  2001/08/15 23:43:18  richard
1120 # Fixed some isFooTypes that I missed.
1121 # Refactored some code in the CGI code.
1123 # Revision 1.20  2001/08/12 06:32:36  richard
1124 # using isinstance(blah, Foo) now instead of isFooType
1126 # Revision 1.19  2001/08/07 00:24:42  richard
1127 # stupid typo
1129 # Revision 1.18  2001/08/07 00:15:51  richard
1130 # Added the copyright/license notice to (nearly) all files at request of
1131 # Bizar Software.
1133 # Revision 1.17  2001/08/02 06:38:17  richard
1134 # Roundupdb now appends "mailing list" information to its messages which
1135 # include the e-mail address and web interface address. Templates may
1136 # override this in their db classes to include specific information (support
1137 # instructions, etc).
1139 # Revision 1.16  2001/08/02 05:55:25  richard
1140 # Web edit messages aren't sent to the person who did the edit any more. No
1141 # message is generated if they are the only person on the nosy list.
1143 # Revision 1.15  2001/08/02 00:34:10  richard
1144 # bleah syntax error
1146 # Revision 1.14  2001/08/02 00:26:16  richard
1147 # Changed the order of the information in the message generated by web edits.
1149 # Revision 1.13  2001/07/30 08:12:17  richard
1150 # Added time logging and file uploading to the templates.
1152 # Revision 1.12  2001/07/30 06:26:31  richard
1153 # Added some documentation on how the newblah works.
1155 # Revision 1.11  2001/07/30 06:17:45  richard
1156 # Features:
1157 #  . Added ability for cgi newblah forms to indicate that the new node
1158 #    should be linked somewhere.
1159 # Fixed:
1160 #  . Fixed the agument handling for the roundup-admin find command.
1161 #  . Fixed handling of summary when no note supplied for newblah. Again.
1162 #  . Fixed detection of no form in htmltemplate Field display.
1164 # Revision 1.10  2001/07/30 02:37:34  richard
1165 # Temporary measure until we have decent schema migration...
1167 # Revision 1.9  2001/07/30 01:25:07  richard
1168 # Default implementation is now "classic" rather than "extended" as one would
1169 # expect.
1171 # Revision 1.8  2001/07/29 08:27:40  richard
1172 # Fixed handling of passed-in values in form elements (ie. during a
1173 # drill-down)
1175 # Revision 1.7  2001/07/29 07:01:39  richard
1176 # Added vim command to all source so that we don't get no steenkin' tabs :)
1178 # Revision 1.6  2001/07/29 04:04:00  richard
1179 # Moved some code around allowing for subclassing to change behaviour.
1181 # Revision 1.5  2001/07/28 08:16:52  richard
1182 # New issue form handles lack of note better now.
1184 # Revision 1.4  2001/07/28 00:34:34  richard
1185 # Fixed some non-string node ids.
1187 # Revision 1.3  2001/07/23 03:56:30  richard
1188 # oops, missed a config removal
1190 # Revision 1.2  2001/07/22 12:09:32  richard
1191 # Final commit of Grande Splite
1193 # Revision 1.1  2001/07/22 11:58:35  richard
1194 # More Grande Splite
1197 # vim: set filetype=python ts=4 sw=4 et si