Code

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