Code

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