Code

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