Code

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