Code

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