Code

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