Code

b17745ce97395569970376bb2ee9d5545f0fe8f2
[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.48 2001-11-03 01:30:18 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             self.pagefoot()
546             return
547         self.write('''
548 <p>
549 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
550 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
551 <form action="newuser_action" method=POST>
552 <tr><td align=right><em>Name: </em></td>
553     <td><input name="realname"></td></tr>
554 <tr><td align=right><em>Organisation: </em></td>
555     <td><input name="organisation"></td></tr>
556 <tr><td align=right>E-Mail Address: </td>
557     <td><input name="address"></td></tr>
558 <tr><td align=right><em>Phone: </em></td>
559     <td><input name="phone"></td></tr>
560 <tr><td align=right>Preferred Login name: </td>
561     <td><input name="username"></td></tr>
562 <tr><td align=right>Password: </td>
563     <td><input type="password" name="password"></td></tr>
564 <tr><td align=right>Password Again: </td>
565     <td><input type="password" name="confirm"></td></tr>
566 <tr><td></td>
567     <td><input type="submit" value="Register"></td></tr>
568 </form>
569 </table>
570 ''')
571         self.pagefoot()
573     def login_action(self, message=None):
574         if not self.form.has_key('__login_name'):
575             return self.login(message='Username required')
576         self.user = self.form['__login_name'].value
577         if self.form.has_key('__login_password'):
578             password = self.form['__login_password'].value
579         else:
580             password = ''
581         print self.user, password
582         # make sure the user exists
583         try:
584             uid = self.db.user.lookup(self.user)
585         except KeyError:
586             name = self.user
587             self.make_user_anonymous()
588             return self.login(message='No such user "%s"'%name)
590         # and that the password is correct
591         pw = self.db.user.get(uid, 'password')
592         if password != self.db.user.get(uid, 'password'):
593             self.make_user_anonymous()
594             return self.login(message='Incorrect password')
596         # construct the cookie
597         uid = self.db.user.lookup(self.user)
598         user = binascii.b2a_base64('%s:%s'%(self.user, password)).strip()
599         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
600             ''))
601         self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
602         return self.index()
604     def make_user_anonymous(self):
605         # make us anonymous if we can
606         try:
607             self.db.user.lookup('anonymous')
608             self.user = 'anonymous'
609         except KeyError:
610             self.user = None
612     def logout(self, message=None):
613         self.make_user_anonymous()
614         # construct the logout cookie
615         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
616             ''))
617         now = Cookie._getdate()
618         self.header({'Set-Cookie':
619             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path)})
620         return self.login()
622     def newuser_action(self, message=None):
623         ''' create a new user based on the contents of the form and then
624         set the cookie
625         '''
626         # re-open the database as "admin"
627         self.db.close()
628         self.db = self.instance.open('admin')
630         # TODO: pre-check the required fields and username key property
631         cl = self.db.classes['user']
632         props, dummy = parsePropsFromForm(self.db, cl, self.form)
633         uid = cl.create(**props)
634         self.user = self.db.user.get(uid, 'username')
635         password = self.db.user.get(uid, 'password')
636         # construct the cookie
637         uid = self.db.user.lookup(self.user)
638         user = binascii.b2a_base64('%s:%s'%(self.user, password)).strip()
639         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
640             ''))
641         self.header({'Set-Cookie': 'roundup_user=%s; Path=%s;'%(user, path)})
642         return self.index()
644     def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
645             nre=re.compile(r'new(\w+)')):
647         # determine the uid to use
648         self.db = self.instance.open('admin')
649         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
650         user = 'anonymous'
651         if (cookie.has_key('roundup_user') and
652                 cookie['roundup_user'].value != 'deleted'):
653             cookie = cookie['roundup_user'].value
654             user, password = binascii.a2b_base64(cookie).split(':')
655             # make sure the user exists
656             try:
657                 uid = self.db.user.lookup(user)
658                 # now validate the password
659                 if password != self.db.user.get(uid, 'password'):
660                     user = 'anonymous'
661             except KeyError:
662                 user = 'anonymous'
664         # make sure the anonymous user is valid if we're using it
665         if user == 'anonymous':
666             self.make_user_anonymous()
667         else:
668             self.user = user
669         self.db.close()
671         # re-open the database for real, using the user
672         self.db = self.instance.open(self.user)
674         # now figure which function to call
675         path = self.split_path
676         if not path or path[0] in ('', 'index'):
677             action = 'index'
678         else:
679             action = path[0]
681         # Everthing ignores path[1:]
682         #  - The file download link generator actually relies on this - it
683         #    appends the name of the file to the URL so the download file name
684         #    is correct, but doesn't actually use it.
686         # everyone is allowed to try to log in
687         if action == 'login_action':
688             return self.login_action()
690         # allow anonymous people to register
691         if action == 'newuser_action':
692             # if we don't have a login and anonymous people aren't allowed to
693             # register, then spit up the login form
694             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
695                 return self.login()
696             return self.newuser_action()
698         # make sure totally anonymous access is OK
699         if self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
700             return self.login()
702         # here be the "normal" functionality
703         if action == 'index':
704             return self.index()
705         if action == 'list_classes':
706             return self.classes()
707         if action == 'login':
708             return self.login()
709         if action == 'logout':
710             return self.logout()
711         m = dre.match(action)
712         if m:
713             self.classname = m.group(1)
714             self.nodeid = m.group(2)
715             try:
716                 cl = self.db.classes[self.classname]
717             except KeyError:
718                 raise NotFound
719             try:
720                 cl.get(self.nodeid, 'id')
721             except IndexError:
722                 raise NotFound
723             try:
724                 func = getattr(self, 'show%s'%self.classname)
725             except AttributeError:
726                 raise NotFound
727             return func()
728         m = nre.match(action)
729         if m:
730             self.classname = m.group(1)
731             try:
732                 func = getattr(self, 'new%s'%self.classname)
733             except AttributeError:
734                 raise NotFound
735             return func()
736         self.classname = action
737         try:
738             self.db.getclass(self.classname)
739         except KeyError:
740             raise NotFound
741         self.list()
743     def __del__(self):
744         self.db.close()
747 class ExtendedClient(Client): 
748     '''Includes pages and page heading information that relate to the
749        extended schema.
750     ''' 
751     showsupport = Client.shownode
752     showtimelog = Client.shownode
753     newsupport = Client.newnode
754     newtimelog = Client.newnode
756     default_index_sort = ['-activity']
757     default_index_group = ['priority']
758     default_index_filter = ['status']
759     default_index_columns = ['activity','status','title','assignedto']
760     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
762     def pagehead(self, title, message=None):
763         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
764         machine = self.env['SERVER_NAME']
765         port = self.env['SERVER_PORT']
766         if port != '80': machine = machine + ':' + port
767         base = urlparse.urlunparse(('http', machine, url, None, None, None))
768         if message is not None:
769             message = '<div class="system-msg">%s</div>'%message
770         else:
771             message = ''
772         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
773         user_name = self.user or ''
774         if self.user == 'admin':
775             admin_links = ' | <a href="list_classes">Class List</a>'
776         else:
777             admin_links = ''
778         if self.user not in (None, 'anonymous'):
779             userid = self.db.user.lookup(self.user)
780             user_info = '''
781 <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> |
782 <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> |
783 <a href="user%s">My Details</a> | <a href="logout">Logout</a>
784 '''%(userid, userid, userid)
785         else:
786             user_info = '<a href="login">Login</a>'
787         if self.user is not None:
788             add_links = '''
789 | Add
790 <a href="newissue">Issue</a>,
791 <a href="newsupport">Support</a>,
792 <a href="newuser">User</a>
793 '''
794         else:
795             add_links = ''
796         self.write('''<html><head>
797 <title>%s</title>
798 <style type="text/css">%s</style>
799 </head>
800 <body bgcolor=#ffffff>
801 %s
802 <table width=100%% border=0 cellspacing=0 cellpadding=2>
803 <tr class="location-bar"><td><big><strong>%s</strong></big></td>
804 <td align=right valign=bottom>%s</td></tr>
805 <tr class="location-bar">
806 <td align=left>All
807 <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>,
808 <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>
809 | Unassigned
810 <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>,
811 <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>
812 %s
813 %s</td>
814 <td align=right>%s</td>
815 </table>
816 '''%(title, style, message, title, user_name, add_links, admin_links,
817     user_info))
819 def parsePropsFromForm(db, cl, form, nodeid=0):
820     '''Pull properties for the given class out of the form.
821     '''
822     props = {}
823     changed = []
824     keys = form.keys()
825     num_re = re.compile('^\d+$')
826     for key in keys:
827         if not cl.properties.has_key(key):
828             continue
829         proptype = cl.properties[key]
830         if isinstance(proptype, hyperdb.String):
831             value = form[key].value.strip()
832         elif isinstance(proptype, hyperdb.Password):
833             value = password.Password(form[key].value.strip())
834         elif isinstance(proptype, hyperdb.Date):
835             value = date.Date(form[key].value.strip())
836         elif isinstance(proptype, hyperdb.Interval):
837             value = date.Interval(form[key].value.strip())
838         elif isinstance(proptype, hyperdb.Link):
839             value = form[key].value.strip()
840             # see if it's the "no selection" choice
841             if value == '-1':
842                 # don't set this property
843                 continue
844             else:
845                 # handle key values
846                 link = cl.properties[key].classname
847                 if not num_re.match(value):
848                     try:
849                         value = db.classes[link].lookup(value)
850                     except KeyError:
851                         raise ValueError, 'property "%s": %s not a %s'%(
852                             key, value, link)
853         elif isinstance(proptype, hyperdb.Multilink):
854             value = form[key]
855             if type(value) != type([]):
856                 value = [i.strip() for i in value.value.split(',')]
857             else:
858                 value = [i.value.strip() for i in value]
859             link = cl.properties[key].classname
860             l = []
861             for entry in map(str, value):
862                 if not num_re.match(entry):
863                     try:
864                         entry = db.classes[link].lookup(entry)
865                     except KeyError:
866                         raise ValueError, \
867                             'property "%s": "%s" not an entry of %s'%(key,
868                             entry, link.capitalize())
869                 l.append(entry)
870             l.sort()
871             value = l
872         props[key] = value
873         # if changed, set it
874         if nodeid and value != cl.get(nodeid, key):
875             changed.append(key)
876             props[key] = value
877     return props, changed
880 # $Log: not supported by cvs2svn $
881 # Revision 1.47  2001/11/03 01:29:28  richard
882 # Login page didn't have all close tags.
884 # Revision 1.46  2001/11/03 01:26:55  richard
885 # possibly fix truncated base64'ed user:pass
887 # Revision 1.45  2001/11/01 22:04:37  richard
888 # Started work on supporting a pop3-fetching server
889 # Fixed bugs:
890 #  . bug #477104 ] HTML tag error in roundup-server
891 #  . bug #477107 ] HTTP header problem
893 # Revision 1.44  2001/10/28 23:03:08  richard
894 # Added more useful header to the classic schema.
896 # Revision 1.43  2001/10/24 00:01:42  richard
897 # More fixes to lockout logic.
899 # Revision 1.42  2001/10/23 23:56:03  richard
900 # HTML typo
902 # Revision 1.41  2001/10/23 23:52:35  richard
903 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
905 # Revision 1.40  2001/10/23 23:06:39  richard
906 # Some cleanup.
908 # Revision 1.39  2001/10/23 01:00:18  richard
909 # Re-enabled login and registration access after lopping them off via
910 # disabling access for anonymous users.
911 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
912 # a couple of bugs while I was there. Probably introduced a couple, but
913 # things seem to work OK at the moment.
915 # Revision 1.38  2001/10/22 03:25:01  richard
916 # Added configuration for:
917 #  . anonymous user access and registration (deny/allow)
918 #  . filter "widget" location on index page (top, bottom, both)
919 # Updated some documentation.
921 # Revision 1.37  2001/10/21 07:26:35  richard
922 # feature #473127: Filenames. I modified the file.index and htmltemplate
923 #  source so that the filename is used in the link and the creation
924 #  information is displayed.
926 # Revision 1.36  2001/10/21 04:44:50  richard
927 # bug #473124: UI inconsistency with Link fields.
928 #    This also prompted me to fix a fairly long-standing usability issue -
929 #    that of being able to turn off certain filters.
931 # Revision 1.35  2001/10/21 00:17:54  richard
932 # CGI interface view customisation section may now be hidden (patch from
933 #  Roch'e Compaan.)
935 # Revision 1.34  2001/10/20 11:58:48  richard
936 # Catch errors in login - no username or password supplied.
937 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
939 # Revision 1.33  2001/10/17 00:18:41  richard
940 # Manually constructing cookie headers now.
942 # Revision 1.32  2001/10/16 03:36:21  richard
943 # CGI interface wasn't handling checkboxes at all.
945 # Revision 1.31  2001/10/14 10:55:00  richard
946 # Handle empty strings in HTML template Link function
948 # Revision 1.30  2001/10/09 07:38:58  richard
949 # Pushed the base code for the extended schema CGI interface back into the
950 # code cgi_client module so that future updates will be less painful.
951 # Also removed a debugging print statement from cgi_client.
953 # Revision 1.29  2001/10/09 07:25:59  richard
954 # Added the Password property type. See "pydoc roundup.password" for
955 # implementation details. Have updated some of the documentation too.
957 # Revision 1.28  2001/10/08 00:34:31  richard
958 # Change message was stuffing up for multilinks with no key property.
960 # Revision 1.27  2001/10/05 02:23:24  richard
961 #  . roundup-admin create now prompts for property info if none is supplied
962 #    on the command-line.
963 #  . hyperdb Class getprops() method may now return only the mutable
964 #    properties.
965 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
966 #    now support anonymous user access (read-only, unless there's an
967 #    "anonymous" user, in which case write access is permitted). Login
968 #    handling has been moved into cgi_client.Client.main()
969 #  . The "extended" schema is now the default in roundup init.
970 #  . The schemas have had their page headings modified to cope with the new
971 #    login handling. Existing installations should copy the interfaces.py
972 #    file from the roundup lib directory to their instance home.
973 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
974 #    Ping - has been removed.
975 #  . Fixed a whole bunch of places in the CGI interface where we should have
976 #    been returning Not Found instead of throwing an exception.
977 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
978 #    an item now throws an exception.
980 # Revision 1.26  2001/09/12 08:31:42  richard
981 # handle cases where mime type is not guessable
983 # Revision 1.25  2001/08/29 05:30:49  richard
984 # change messages weren't being saved when there was no-one on the nosy list.
986 # Revision 1.24  2001/08/29 04:49:39  richard
987 # didn't clean up fully after debugging :(
989 # Revision 1.23  2001/08/29 04:47:18  richard
990 # Fixed CGI client change messages so they actually include the properties
991 # changed (again).
993 # Revision 1.22  2001/08/17 00:08:10  richard
994 # reverted back to sending messages always regardless of who is doing the web
995 # edit. change notes weren't being saved. bleah. hackish.
997 # Revision 1.21  2001/08/15 23:43:18  richard
998 # Fixed some isFooTypes that I missed.
999 # Refactored some code in the CGI code.
1001 # Revision 1.20  2001/08/12 06:32:36  richard
1002 # using isinstance(blah, Foo) now instead of isFooType
1004 # Revision 1.19  2001/08/07 00:24:42  richard
1005 # stupid typo
1007 # Revision 1.18  2001/08/07 00:15:51  richard
1008 # Added the copyright/license notice to (nearly) all files at request of
1009 # Bizar Software.
1011 # Revision 1.17  2001/08/02 06:38:17  richard
1012 # Roundupdb now appends "mailing list" information to its messages which
1013 # include the e-mail address and web interface address. Templates may
1014 # override this in their db classes to include specific information (support
1015 # instructions, etc).
1017 # Revision 1.16  2001/08/02 05:55:25  richard
1018 # Web edit messages aren't sent to the person who did the edit any more. No
1019 # message is generated if they are the only person on the nosy list.
1021 # Revision 1.15  2001/08/02 00:34:10  richard
1022 # bleah syntax error
1024 # Revision 1.14  2001/08/02 00:26:16  richard
1025 # Changed the order of the information in the message generated by web edits.
1027 # Revision 1.13  2001/07/30 08:12:17  richard
1028 # Added time logging and file uploading to the templates.
1030 # Revision 1.12  2001/07/30 06:26:31  richard
1031 # Added some documentation on how the newblah works.
1033 # Revision 1.11  2001/07/30 06:17:45  richard
1034 # Features:
1035 #  . Added ability for cgi newblah forms to indicate that the new node
1036 #    should be linked somewhere.
1037 # Fixed:
1038 #  . Fixed the agument handling for the roundup-admin find command.
1039 #  . Fixed handling of summary when no note supplied for newblah. Again.
1040 #  . Fixed detection of no form in htmltemplate Field display.
1042 # Revision 1.10  2001/07/30 02:37:34  richard
1043 # Temporary measure until we have decent schema migration...
1045 # Revision 1.9  2001/07/30 01:25:07  richard
1046 # Default implementation is now "classic" rather than "extended" as one would
1047 # expect.
1049 # Revision 1.8  2001/07/29 08:27:40  richard
1050 # Fixed handling of passed-in values in form elements (ie. during a
1051 # drill-down)
1053 # Revision 1.7  2001/07/29 07:01:39  richard
1054 # Added vim command to all source so that we don't get no steenkin' tabs :)
1056 # Revision 1.6  2001/07/29 04:04:00  richard
1057 # Moved some code around allowing for subclassing to change behaviour.
1059 # Revision 1.5  2001/07/28 08:16:52  richard
1060 # New issue form handles lack of note better now.
1062 # Revision 1.4  2001/07/28 00:34:34  richard
1063 # Fixed some non-string node ids.
1065 # Revision 1.3  2001/07/23 03:56:30  richard
1066 # oops, missed a config removal
1068 # Revision 1.2  2001/07/22 12:09:32  richard
1069 # Final commit of Grande Splite
1071 # Revision 1.1  2001/07/22 11:58:35  richard
1072 # More Grande Splite
1075 # vim: set filetype=python ts=4 sw=4 et si