Code

6fe8b288cede65e623c1c9d0baf8472655b55cd2
[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.79 2001-12-10 22:20:01 richard 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 not changed.has_key('status'):
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['status'] = chatting_id
331                 # get the change note
332                 change_note = cl.generateChangeNote(self.nodeid, changed)
334                 # make the changes
335                 cl.set(self.nodeid, **props)
337                 # handle linked nodes and change message generation
338                 self._post_editnode(self.nodeid, change_note)
340                 # and some nice feedback for the user
341                 if changed:
342                     message = _('%(changes)s edited ok')%{'changes':
343                         ', '.join(changed.keys())}
344                 elif self.form.has_key('__note') and self.form['__note'].value:
345                     message = _('note added')
346                 else:
347                     message = _('nothing changed')
348             except:
349                 self.db.rollback()
350                 s = StringIO.StringIO()
351                 traceback.print_exc(None, s)
352                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
354         # now the display
355         id = self.nodeid
356         if cl.getkey():
357             id = cl.get(id, cl.getkey())
358         self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
360         nodeid = self.nodeid
362         # use the template to display the item
363         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
364         item.render(nodeid)
366         self.pagefoot()
367     showissue = shownode
368     showmsg = shownode
370     def showuser(self, message=None):
371         '''Display a user page for editing. Make sure the user is allowed
372             to edit this node, and also check for password changes.
373         '''
374         if self.user == 'anonymous':
375             raise Unauthorised
377         user = self.db.user
379         # get the username of the node being edited
380         node_user = user.get(self.nodeid, 'username')
382         if self.user not in ('admin', node_user):
383             raise Unauthorised
385         #
386         # perform any editing
387         #
388         keys = self.form.keys()
389         num_re = re.compile('^\d+$')
390         if keys:
391             try:
392                 props, changed = parsePropsFromForm(self.db, user, self.form,
393                     self.nodeid)
394                 set_cookie = 0
395                 if self.nodeid == self.getuid() and 'password' in changed:
396                     password = self.form['password'].value.strip()
397                     if password:
398                         set_cookie = password
399                     else:
400                         del props['password']
401                         del changed[changed.index('password')]
402                 user.set(self.nodeid, **props)
403                 self._post_editnode(self.nodeid)
404                 # and some feedback for the user
405                 message = _('%(changes)s edited ok')%{'changes':
406                     ', '.join(changed.keys())}
407             except:
408                 self.db.rollback()
409                 s = StringIO.StringIO()
410                 traceback.print_exc(None, s)
411                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
412         else:
413             set_cookie = 0
415         # fix the cookie if the password has changed
416         if set_cookie:
417             self.set_cookie(self.user, set_cookie)
419         #
420         # now the display
421         #
422         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
424         # use the template to display the item
425         item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
426         item.render(self.nodeid)
427         self.pagefoot()
429     def showfile(self):
430         ''' display a file
431         '''
432         nodeid = self.nodeid
433         cl = self.db.file
434         mime_type = cl.get(nodeid, 'type')
435         if mime_type == 'message/rfc822':
436             mime_type = 'text/plain'
437         self.header(headers={'Content-Type': mime_type})
438         self.write(cl.get(nodeid, 'content'))
440     def _createnode(self):
441         ''' create a node based on the contents of the form
442         '''
443         cl = self.db.classes[self.classname]
444         props, dummy = parsePropsFromForm(self.db, cl, self.form)
446         # set status to 'unread' if not specified - a status of '- no
447         # selection -' doesn't make sense
448         if not props.has_key('status'):
449             try:
450                 unread_id = self.db.status.lookup('unread')
451             except KeyError:
452                 pass
453             else:
454                 props['status'] = unread_id
455         return cl.create(**props)
457     def _post_editnode(self, nid, change_note=''):
458         ''' do the linking and message sending part of the node creation
459         '''
460         cn = self.classname
461         cl = self.db.classes[cn]
462         # link if necessary
463         keys = self.form.keys()
464         for key in keys:
465             if key == ':multilink':
466                 value = self.form[key].value
467                 if type(value) != type([]): value = [value]
468                 for value in value:
469                     designator, property = value.split(':')
470                     link, nodeid = roundupdb.splitDesignator(designator)
471                     link = self.db.classes[link]
472                     value = link.get(nodeid, property)
473                     value.append(nid)
474                     link.set(nodeid, **{property: value})
475             elif key == ':link':
476                 value = self.form[key].value
477                 if type(value) != type([]): value = [value]
478                 for value in value:
479                     designator, property = value.split(':')
480                     link, nodeid = roundupdb.splitDesignator(designator)
481                     link = self.db.classes[link]
482                     link.set(nodeid, **{property: nid})
484         # handle file attachments
485         files = cl.get(nid, 'files')
486         if self.form.has_key('__file'):
487             file = self.form['__file']
488             if file.filename:
489                 mime_type = mimetypes.guess_type(file.filename)[0]
490                 if not mime_type:
491                     mime_type = "application/octet-stream"
492                 # create the new file entry
493                 files.append(self.db.file.create(type=mime_type,
494                     name=file.filename, content=file.file.read()))
495                 # and save the reference
496                 cl.set(nid, files=files)
498         #
499         # generate an edit message
500         #
502         # we don't want to do a message if none of the following is true...
503         props = cl.getprops()
504         note = None
505         if self.form.has_key('__note'):
506             note = self.form['__note'].value
507         if not props.has_key('messages'):
508             return
509         if not isinstance(props['messages'], hyperdb.Multilink):
510             return
511         if not props['messages'].classname == 'msg':
512             return
513         if not (len(cl.get(nid, 'nosy', [])) or note):
514             return
516         # handle the note
517         if note:
518             if '\n' in note:
519                 summary = re.split(r'\n\r?', note)[0]
520             else:
521                 summary = note
522             m = ['%s\n'%note]
523         else:
524             summary = _('This %(classname)s has been edited through'
525                 ' the web.\n')%{'classname': cn}
526             m = [summary]
528         # append the change note
529         if change_note:
530             m.append(change_note)
532         # now create the message
533         content = '\n'.join(m)
534         message_id = self.db.msg.create(author=self.getuid(),
535             recipients=[], date=date.Date('.'), summary=summary,
536             content=content, files=files)
538         # update the messages property
539         messages = cl.get(nid, 'messages')
540         messages.append(message_id)
541         cl.set(nid, messages=messages, files=files)
543     def newnode(self, message=None):
544         ''' Add a new node to the database.
545         
546         The form works in two modes: blank form and submission (that is,
547         the submission goes to the same URL). **Eventually this means that
548         the form will have previously entered information in it if
549         submission fails.
551         The new node will be created with the properties specified in the
552         form submission. For multilinks, multiple form entries are handled,
553         as are prop=value,value,value. You can't mix them though.
555         If the new node is to be referenced from somewhere else immediately
556         (ie. the new node is a file that is to be attached to a support
557         issue) then supply one of these arguments in addition to the usual
558         form entries:
559             :link=designator:property
560             :multilink=designator:property
561         ... which means that once the new node is created, the "property"
562         on the node given by "designator" should now reference the new
563         node's id. The node id will be appended to the multilink.
564         '''
565         cn = self.classname
566         cl = self.db.classes[cn]
568         # possibly perform a create
569         keys = self.form.keys()
570         if [i for i in keys if i[0] != ':']:
571             props = {}
572             try:
573                 nid = self._createnode()
574                 # handle linked nodes and change message generation
575                 self._post_editnode(nid)
576                 # and some nice feedback for the user
577                 message = _('%(classname)s created ok')%{'classname': cn}
578             except:
579                 self.db.rollback()
580                 s = StringIO.StringIO()
581                 traceback.print_exc(None, s)
582                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
583         self.pagehead(_('New %(classname)s')%{'classname':
584              self.classname.capitalize()}, message)
586         # call the template
587         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
588             self.classname)
589         newitem.render(self.form)
591         self.pagefoot()
592     newissue = newnode
593     newuser = newnode
595     def newfile(self, message=None):
596         ''' Add a new file to the database.
597         
598         This form works very much the same way as newnode - it just has a
599         file upload.
600         '''
601         cn = self.classname
602         cl = self.db.classes[cn]
604         # possibly perform a create
605         keys = self.form.keys()
606         if [i for i in keys if i[0] != ':']:
607             try:
608                 file = self.form['content']
609                 mime_type = mimetypes.guess_type(file.filename)[0]
610                 if not mime_type:
611                     mime_type = "application/octet-stream"
612                 # save the file
613                 nid = cl.create(content=file.file.read(), type=mime_type,
614                     name=file.filename)
615                 # handle linked nodes
616                 self._post_editnode(nid)
617                 # and some nice feedback for the user
618                 message = _('%(classname)s created ok')%{'classname': cn}
619             except:
620                 self.db.rollback()
621                 s = StringIO.StringIO()
622                 traceback.print_exc(None, s)
623                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
625         self.pagehead(_('New %(classname)s')%{'classname':
626              self.classname.capitalize()}, message)
627         newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
628             self.classname)
629         newitem.render(self.form)
630         self.pagefoot()
632     def classes(self, message=None):
633         ''' display a list of all the classes in the database
634         '''
635         if self.user == 'admin':
636             self.pagehead(_('Table of classes'), message)
637             classnames = self.db.classes.keys()
638             classnames.sort()
639             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
640             for cn in classnames:
641                 cl = self.db.getclass(cn)
642                 self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
643                 for key, value in cl.properties.items():
644                     if value is None: value = ''
645                     else: value = str(value)
646                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
647                         key, cgi.escape(value)))
648             self.write('</table>')
649             self.pagefoot()
650         else:
651             raise Unauthorised
653     def login(self, message=None, newuser_form=None, action='index'):
654         '''Display a login page.
655         '''
656         self.pagehead(_('Login to roundup'), message)
657         self.write(_('''
658 <table>
659 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
660 <form action="login_action" method=POST>
661 <input type="hidden" name="__destination_url" value="%(action)s">
662 <tr><td align=right>Login name: </td>
663     <td><input name="__login_name"></td></tr>
664 <tr><td align=right>Password: </td>
665     <td><input type="password" name="__login_password"></td></tr>
666 <tr><td></td>
667     <td><input type="submit" value="Log In"></td></tr>
668 </form>
669 ''')%locals())
670         if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
671             self.write('</table>')
672             self.pagefoot()
673             return
674         values = {'realname': '', 'organisation': '', 'address': '',
675             'phone': '', 'username': '', 'password': '', 'confirm': '',
676             'action': action}
677         if newuser_form is not None:
678             for key in newuser_form.keys():
679                 values[key] = newuser_form[key].value
680         self.write(_('''
681 <p>
682 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
683 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
684 <form action="newuser_action" method=POST>
685 <input type="hidden" name="__destination_url" value="%(action)s">
686 <tr><td align=right><em>Name: </em></td>
687     <td><input name="realname" value="%(realname)s"></td></tr>
688 <tr><td align=right><em>Organisation: </em></td>
689     <td><input name="organisation" value="%(organisation)s"></td></tr>
690 <tr><td align=right>E-Mail Address: </td>
691     <td><input name="address" value="%(address)s"></td></tr>
692 <tr><td align=right><em>Phone: </em></td>
693     <td><input name="phone" value="%(phone)s"></td></tr>
694 <tr><td align=right>Preferred Login name: </td>
695     <td><input name="username" value="%(username)s"></td></tr>
696 <tr><td align=right>Password: </td>
697     <td><input type="password" name="password" value="%(password)s"></td></tr>
698 <tr><td align=right>Password Again: </td>
699     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
700 <tr><td></td>
701     <td><input type="submit" value="Register"></td></tr>
702 </form>
703 </table>
704 ''')%values)
705         self.pagefoot()
707     def login_action(self, message=None):
708         '''Attempt to log a user in and set the cookie
710         returns 0 if a page is generated as a result of this call, and
711         1 if not (ie. the login is successful
712         '''
713         if not self.form.has_key('__login_name'):
714             self.login(message=_('Username required'))
715             return 0
716         self.user = self.form['__login_name'].value
717         if self.form.has_key('__login_password'):
718             password = self.form['__login_password'].value
719         else:
720             password = ''
721         # make sure the user exists
722         try:
723             uid = self.db.user.lookup(self.user)
724         except KeyError:
725             name = self.user
726             self.make_user_anonymous()
727             action = self.form['__destination_url'].value
728             self.login(message=_('No such user "%(name)s"')%locals(),
729                 action=action)
730             return 0
732         # and that the password is correct
733         pw = self.db.user.get(uid, 'password')
734         if password != pw:
735             self.make_user_anonymous()
736             action = self.form['__destination_url'].value
737             self.login(message=_('Incorrect password'), action=action)
738             return 0
740         self.set_cookie(self.user, password)
741         return 1
743     def newuser_action(self, message=None):
744         '''Attempt to create a new user based on the contents of the form
745         and then set the cookie.
747         return 1 on successful login
748         '''
749         # re-open the database as "admin"
750         self.db = self.instance.open('admin')
752         # TODO: pre-check the required fields and username key property
753         cl = self.db.user
754         try:
755             props, dummy = parsePropsFromForm(self.db, cl, self.form)
756             uid = cl.create(**props)
757         except ValueError, message:
758             action = self.form['__destination_url'].value
759             self.login(message, action=action)
760             return 0
761         self.user = cl.get(uid, 'username')
762         password = cl.get(uid, 'password')
763         self.set_cookie(self.user, self.form['password'].value)
764         return 1
766     def set_cookie(self, user, password):
767         # construct the cookie
768         user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
769         if user[-1] == '=':
770           if user[-2] == '=':
771             user = user[:-2]
772           else:
773             user = user[:-1]
774         expire = Cookie._getdate(86400*365)
775         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
776         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
777             user, expire, path)})
779     def make_user_anonymous(self):
780         # make us anonymous if we can
781         try:
782             self.db.user.lookup('anonymous')
783             self.user = 'anonymous'
784         except KeyError:
785             self.user = None
787     def logout(self, message=None):
788         self.make_user_anonymous()
789         # construct the logout cookie
790         now = Cookie._getdate()
791         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
792         self.header({'Set-Cookie':
793             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
794             path)})
795         self.login()
798     def main(self):
799         '''Wrap the database accesses so we can close the database cleanly
800         '''
801         # determine the uid to use
802         self.db = self.instance.open('admin')
803         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
804         user = 'anonymous'
805         if (cookie.has_key('roundup_user') and
806                 cookie['roundup_user'].value != 'deleted'):
807             cookie = cookie['roundup_user'].value
808             if len(cookie)%4:
809               cookie = cookie + '='*(4-len(cookie)%4)
810             try:
811                 user, password = binascii.a2b_base64(cookie).split(':')
812             except (TypeError, binascii.Error, binascii.Incomplete):
813                 # damaged cookie!
814                 user, password = 'anonymous', ''
816             # make sure the user exists
817             try:
818                 uid = self.db.user.lookup(user)
819                 # now validate the password
820                 if password != self.db.user.get(uid, 'password'):
821                     user = 'anonymous'
822             except KeyError:
823                 user = 'anonymous'
825         # make sure the anonymous user is valid if we're using it
826         if user == 'anonymous':
827             self.make_user_anonymous()
828         else:
829             self.user = user
831         # re-open the database for real, using the user
832         self.db = self.instance.open(self.user)
834         # now figure which function to call
835         path = self.split_path
837         # default action to index if the path has no information in it
838         if not path or path[0] in ('', 'index'):
839             action = 'index'
840         else:
841             action = path[0]
843         # Everthing ignores path[1:]
844         #  - The file download link generator actually relies on this - it
845         #    appends the name of the file to the URL so the download file name
846         #    is correct, but doesn't actually use it.
848         # everyone is allowed to try to log in
849         if action == 'login_action':
850             # try to login
851             if not self.login_action():
852                 return
853             # figure the resulting page
854             action = self.form['__destination_url'].value
855             if not action:
856                 action = 'index'
857             self.do_action(action)
858             return
860         # allow anonymous people to register
861         if action == 'newuser_action':
862             # if we don't have a login and anonymous people aren't allowed to
863             # register, then spit up the login form
864             if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
865                 if action == 'login':
866                     self.login()         # go to the index after login
867                 else:
868                     self.login(action=action)
869                 return
870             # try to add the user
871             if not self.newuser_action():
872                 return
873             # figure the resulting page
874             action = self.form['__destination_url'].value
875             if not action:
876                 action = 'index'
878         # no login or registration, make sure totally anonymous access is OK
879         elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
880             if action == 'login':
881                 self.login()             # go to the index after login
882             else:
883                 self.login(action=action)
884             return
886         # just a regular action
887         self.do_action(action)
889         # commit all changes to the database
890         self.db.commit()
892     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
893             nre=re.compile(r'new(\w+)')):
894         '''Figure the user's action and do it.
895         '''
896         # here be the "normal" functionality
897         if action == 'index':
898             self.index()
899             return
900         if action == 'list_classes':
901             self.classes()
902             return
903         if action == 'login':
904             self.login()
905             return
906         if action == 'logout':
907             self.logout()
908             return
909         m = dre.match(action)
910         if m:
911             self.classname = m.group(1)
912             self.nodeid = m.group(2)
913             try:
914                 cl = self.db.classes[self.classname]
915             except KeyError:
916                 raise NotFound
917             try:
918                 cl.get(self.nodeid, 'id')
919             except IndexError:
920                 raise NotFound
921             try:
922                 func = getattr(self, 'show%s'%self.classname)
923             except AttributeError:
924                 raise NotFound
925             func()
926             return
927         m = nre.match(action)
928         if m:
929             self.classname = m.group(1)
930             try:
931                 func = getattr(self, 'new%s'%self.classname)
932             except AttributeError:
933                 raise NotFound
934             func()
935             return
936         self.classname = action
937         try:
938             self.db.getclass(self.classname)
939         except KeyError:
940             raise NotFound
941         self.list()
944 class ExtendedClient(Client): 
945     '''Includes pages and page heading information that relate to the
946        extended schema.
947     ''' 
948     showsupport = Client.shownode
949     showtimelog = Client.shownode
950     newsupport = Client.newnode
951     newtimelog = Client.newnode
953     default_index_sort = ['-activity']
954     default_index_group = ['priority']
955     default_index_filter = ['status']
956     default_index_columns = ['activity','status','title','assignedto']
957     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
959     def pagehead(self, title, message=None):
960         url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
961         machine = self.env['SERVER_NAME']
962         port = self.env['SERVER_PORT']
963         if port != '80': machine = machine + ':' + port
964         base = urlparse.urlunparse(('http', machine, url, None, None, None))
965         if message is not None:
966             message = _('<div class="system-msg">%(message)s</div>')%locals()
967         else:
968             message = ''
969         style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
970         user_name = self.user or ''
971         if self.user == 'admin':
972             admin_links = _(' | <a href="list_classes">Class List</a>' \
973                           ' | <a href="user">User List</a>')
974         else:
975             admin_links = ''
976         if self.user not in (None, 'anonymous'):
977             userid = self.db.user.lookup(self.user)
978             user_info = _('''
979 <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> |
980 <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> |
981 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
982 ''')%locals()
983         else:
984             user_info = _('<a href="login">Login</a>')
985         if self.user is not None:
986             add_links = _('''
987 | Add
988 <a href="newissue">Issue</a>,
989 <a href="newsupport">Support</a>,
990 <a href="newuser">User</a>
991 ''')
992         else:
993             add_links = ''
994         self.write(_('''<html><head>
995 <title>%(title)s</title>
996 <style type="text/css">%(style)s</style>
997 </head>
998 <body bgcolor=#ffffff>
999 %(message)s
1000 <table width=100%% border=0 cellspacing=0 cellpadding=2>
1001 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
1002 <td align=right valign=bottom>%(user_name)s</td></tr>
1003 <tr class="location-bar">
1004 <td align=left>All
1005 <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>,
1006 <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>
1007 | Unassigned
1008 <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>,
1009 <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>
1010 %(add_links)s
1011 %(admin_links)s</td>
1012 <td align=right>%(user_info)s</td>
1013 </table>
1014 ''')%locals())
1016 def parsePropsFromForm(db, cl, form, nodeid=0):
1017     '''Pull properties for the given class out of the form.
1018     '''
1019     props = {}
1020     changed = {}
1021     keys = form.keys()
1022     num_re = re.compile('^\d+$')
1023     for key in keys:
1024         if not cl.properties.has_key(key):
1025             continue
1026         proptype = cl.properties[key]
1027         if isinstance(proptype, hyperdb.String):
1028             value = form[key].value.strip()
1029         elif isinstance(proptype, hyperdb.Password):
1030             value = password.Password(form[key].value.strip())
1031         elif isinstance(proptype, hyperdb.Date):
1032             value = date.Date(form[key].value.strip())
1033         elif isinstance(proptype, hyperdb.Interval):
1034             value = date.Interval(form[key].value.strip())
1035         elif isinstance(proptype, hyperdb.Link):
1036             value = form[key].value.strip()
1037             # see if it's the "no selection" choice
1038             if value == '-1':
1039                 # don't set this property
1040                 continue
1041             else:
1042                 # handle key values
1043                 link = cl.properties[key].classname
1044                 if not num_re.match(value):
1045                     try:
1046                         value = db.classes[link].lookup(value)
1047                     except KeyError:
1048                         raise ValueError, _('property "%(propname)s": '
1049                             '%(value)s not a %(classname)s')%{'propname':key, 
1050                             'value': value, 'classname': link}
1051         elif isinstance(proptype, hyperdb.Multilink):
1052             value = form[key]
1053             if type(value) != type([]):
1054                 value = [i.strip() for i in value.value.split(',')]
1055             else:
1056                 value = [i.value.strip() for i in value]
1057             link = cl.properties[key].classname
1058             l = []
1059             for entry in map(str, value):
1060                 if entry == '': continue
1061                 if not num_re.match(entry):
1062                     try:
1063                         entry = db.classes[link].lookup(entry)
1064                     except KeyError:
1065                         raise ValueError, _('property "%(propname)s": '
1066                             '"%(value)s" not an entry of %(classname)s')%{
1067                             'propname':key, 'value': entry, 'classname': link}
1068                 l.append(entry)
1069             l.sort()
1070             value = l
1071         props[key] = value
1073         # get the old value
1074         if nodeid:
1075             try:
1076                 existing = cl.get(nodeid, key)
1077             except KeyError:
1078                 # this might be a new property for which there is no existing
1079                 # value
1080                 if not cl.properties.has_key(key): raise
1082         # if changed, set it
1083         if nodeid and value != existing:
1084             changed[key] = value
1085             props[key] = value
1086     return props, changed
1089 # $Log: not supported by cvs2svn $
1090 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1091 # Fixed small bug that prevented adding issues through the web.
1093 # Revision 1.77  2001/12/06 22:48:29  richard
1094 # files multilink was being nuked in post_edit_node
1096 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1097 # Removed generation of change note from "sendmessage" in roundupdb.py.
1098 # The change note is now generated when the message is created.
1100 # Revision 1.75  2001/12/04 01:25:08  richard
1101 # Added some rollbacks where we were catching exceptions that would otherwise
1102 # have stopped committing.
1104 # Revision 1.74  2001/12/02 05:06:16  richard
1105 # . We now use weakrefs in the Classes to keep the database reference, so
1106 #   the close() method on the database is no longer needed.
1107 #   I bumped the minimum python requirement up to 2.1 accordingly.
1108 # . #487480 ] roundup-server
1109 # . #487476 ] INSTALL.txt
1111 # I also cleaned up the change message / post-edit stuff in the cgi client.
1112 # There's now a clearly marked "TODO: append the change note" where I believe
1113 # the change note should be added there. The "changes" list will obviously
1114 # have to be modified to be a dict of the changes, or somesuch.
1116 # More testing needed.
1118 # Revision 1.73  2001/12/01 07:17:50  richard
1119 # . We now have basic transaction support! Information is only written to
1120 #   the database when the commit() method is called. Only the anydbm
1121 #   backend is modified in this way - neither of the bsddb backends have been.
1122 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1123 #   doesn't have a commit command, so interactive users can't commit...)
1124 # . Fixed login/registration forwarding the user to the right page (or not,
1125 #   on a failure)
1127 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1128 # Links in page header are now consistent with default sort order.
1130 # Fixed bugs:
1131 #     - When login failed the list of issues were still rendered.
1132 #     - User was redirected to index page and not to his destination url
1133 #       if his first login attempt failed.
1135 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1136 # Property changes are now completely traceable, whether changes are
1137 # made through the web or by email
1139 # Revision 1.70  2001/11/30 00:06:29  richard
1140 # Converted roundup/cgi_client.py to use _()
1141 # Added the status file, I18N_PROGRESS.txt
1143 # Revision 1.69  2001/11/29 23:19:51  richard
1144 # Removed the "This issue has been edited through the web" when a valid
1145 # change note is supplied.
1147 # Revision 1.68  2001/11/29 04:57:23  richard
1148 # a little comment
1150 # Revision 1.67  2001/11/28 21:55:35  richard
1151 #  . login_action and newuser_action return values were being ignored
1152 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1153 #    gateway.
1154 #  (also a minor cleanup in hyperdb)
1156 # Revision 1.66  2001/11/27 03:00:50  richard
1157 # couple of bugfixes from latest patch integration
1159 # Revision 1.65  2001/11/26 23:00:53  richard
1160 # This config stuff is getting to be a real mess...
1162 # Revision 1.64  2001/11/26 22:56:35  richard
1163 # typo
1165 # Revision 1.63  2001/11/26 22:55:56  richard
1166 # Feature:
1167 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1168 #    the instance.
1169 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1170 #    signature info in e-mails.
1171 #  . Some more flexibility in the mail gateway and more error handling.
1172 #  . Login now takes you to the page you back to the were denied access to.
1174 # Fixed:
1175 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1177 # Revision 1.62  2001/11/24 00:45:42  jhermann
1178 # typeof() instead of type(): avoid clash with database field(?) "type"
1180 # Fixes this traceback:
1182 # Traceback (most recent call last):
1183 #   File "roundup\cgi_client.py", line 535, in newnode
1184 #     self._post_editnode(nid)
1185 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1186 #     if type(value) != type([]): value = [value]
1187 # UnboundLocalError: local variable 'type' referenced before assignment
1189 # Revision 1.61  2001/11/22 15:46:42  jhermann
1190 # Added module docstrings to all modules.
1192 # Revision 1.60  2001/11/21 22:57:28  jhermann
1193 # Added dummy hooks for I18N and some preliminary (test) markup of
1194 # translatable messages
1196 # Revision 1.59  2001/11/21 03:21:13  richard
1197 # oops
1199 # Revision 1.58  2001/11/21 03:11:28  richard
1200 # Better handling of new properties.
1202 # Revision 1.57  2001/11/15 10:24:27  richard
1203 # handle the case where there is no file attached
1205 # Revision 1.56  2001/11/14 21:35:21  richard
1206 #  . users may attach files to issues (and support in ext) through the web now
1208 # Revision 1.55  2001/11/07 02:34:06  jhermann
1209 # Handling of damaged login cookies
1211 # Revision 1.54  2001/11/07 01:16:12  richard
1212 # Remove the '=' padding from cookie value so quoting isn't an issue.
1214 # Revision 1.53  2001/11/06 23:22:05  jhermann
1215 # More IE fixes: it does not like quotes around cookie values; in the
1216 # hope this does not break anything for other browser; if it does, we
1217 # need to check HTTP_USER_AGENT
1219 # Revision 1.52  2001/11/06 23:11:22  jhermann
1220 # Fixed debug output in page footer; added expiry date to the login cookie
1221 # (expires 1 year in the future) to prevent probs with certain versions
1222 # of IE
1224 # Revision 1.51  2001/11/06 22:00:34  jhermann
1225 # Get debug level from ROUNDUP_DEBUG env var
1227 # Revision 1.50  2001/11/05 23:45:40  richard
1228 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1229 # Also made it present nicer error messages (not tracebacks).
1231 # Revision 1.49  2001/11/04 03:07:12  richard
1232 # Fixed various cookie-related bugs:
1233 #  . bug #477685 ] base64.decodestring breaks
1234 #  . bug #477837 ] lynx does not like the cookie
1235 #  . bug #477892 ] Password edit doesn't fix login cookie
1236 # Also closed a security hole - a logged-in user could edit another user's
1237 # details.
1239 # Revision 1.48  2001/11/03 01:30:18  richard
1240 # Oops. uses pagefoot now.
1242 # Revision 1.47  2001/11/03 01:29:28  richard
1243 # Login page didn't have all close tags.
1245 # Revision 1.46  2001/11/03 01:26:55  richard
1246 # possibly fix truncated base64'ed user:pass
1248 # Revision 1.45  2001/11/01 22:04:37  richard
1249 # Started work on supporting a pop3-fetching server
1250 # Fixed bugs:
1251 #  . bug #477104 ] HTML tag error in roundup-server
1252 #  . bug #477107 ] HTTP header problem
1254 # Revision 1.44  2001/10/28 23:03:08  richard
1255 # Added more useful header to the classic schema.
1257 # Revision 1.43  2001/10/24 00:01:42  richard
1258 # More fixes to lockout logic.
1260 # Revision 1.42  2001/10/23 23:56:03  richard
1261 # HTML typo
1263 # Revision 1.41  2001/10/23 23:52:35  richard
1264 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1266 # Revision 1.40  2001/10/23 23:06:39  richard
1267 # Some cleanup.
1269 # Revision 1.39  2001/10/23 01:00:18  richard
1270 # Re-enabled login and registration access after lopping them off via
1271 # disabling access for anonymous users.
1272 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1273 # a couple of bugs while I was there. Probably introduced a couple, but
1274 # things seem to work OK at the moment.
1276 # Revision 1.38  2001/10/22 03:25:01  richard
1277 # Added configuration for:
1278 #  . anonymous user access and registration (deny/allow)
1279 #  . filter "widget" location on index page (top, bottom, both)
1280 # Updated some documentation.
1282 # Revision 1.37  2001/10/21 07:26:35  richard
1283 # feature #473127: Filenames. I modified the file.index and htmltemplate
1284 #  source so that the filename is used in the link and the creation
1285 #  information is displayed.
1287 # Revision 1.36  2001/10/21 04:44:50  richard
1288 # bug #473124: UI inconsistency with Link fields.
1289 #    This also prompted me to fix a fairly long-standing usability issue -
1290 #    that of being able to turn off certain filters.
1292 # Revision 1.35  2001/10/21 00:17:54  richard
1293 # CGI interface view customisation section may now be hidden (patch from
1294 #  Roch'e Compaan.)
1296 # Revision 1.34  2001/10/20 11:58:48  richard
1297 # Catch errors in login - no username or password supplied.
1298 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1300 # Revision 1.33  2001/10/17 00:18:41  richard
1301 # Manually constructing cookie headers now.
1303 # Revision 1.32  2001/10/16 03:36:21  richard
1304 # CGI interface wasn't handling checkboxes at all.
1306 # Revision 1.31  2001/10/14 10:55:00  richard
1307 # Handle empty strings in HTML template Link function
1309 # Revision 1.30  2001/10/09 07:38:58  richard
1310 # Pushed the base code for the extended schema CGI interface back into the
1311 # code cgi_client module so that future updates will be less painful.
1312 # Also removed a debugging print statement from cgi_client.
1314 # Revision 1.29  2001/10/09 07:25:59  richard
1315 # Added the Password property type. See "pydoc roundup.password" for
1316 # implementation details. Have updated some of the documentation too.
1318 # Revision 1.28  2001/10/08 00:34:31  richard
1319 # Change message was stuffing up for multilinks with no key property.
1321 # Revision 1.27  2001/10/05 02:23:24  richard
1322 #  . roundup-admin create now prompts for property info if none is supplied
1323 #    on the command-line.
1324 #  . hyperdb Class getprops() method may now return only the mutable
1325 #    properties.
1326 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1327 #    now support anonymous user access (read-only, unless there's an
1328 #    "anonymous" user, in which case write access is permitted). Login
1329 #    handling has been moved into cgi_client.Client.main()
1330 #  . The "extended" schema is now the default in roundup init.
1331 #  . The schemas have had their page headings modified to cope with the new
1332 #    login handling. Existing installations should copy the interfaces.py
1333 #    file from the roundup lib directory to their instance home.
1334 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1335 #    Ping - has been removed.
1336 #  . Fixed a whole bunch of places in the CGI interface where we should have
1337 #    been returning Not Found instead of throwing an exception.
1338 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1339 #    an item now throws an exception.
1341 # Revision 1.26  2001/09/12 08:31:42  richard
1342 # handle cases where mime type is not guessable
1344 # Revision 1.25  2001/08/29 05:30:49  richard
1345 # change messages weren't being saved when there was no-one on the nosy list.
1347 # Revision 1.24  2001/08/29 04:49:39  richard
1348 # didn't clean up fully after debugging :(
1350 # Revision 1.23  2001/08/29 04:47:18  richard
1351 # Fixed CGI client change messages so they actually include the properties
1352 # changed (again).
1354 # Revision 1.22  2001/08/17 00:08:10  richard
1355 # reverted back to sending messages always regardless of who is doing the web
1356 # edit. change notes weren't being saved. bleah. hackish.
1358 # Revision 1.21  2001/08/15 23:43:18  richard
1359 # Fixed some isFooTypes that I missed.
1360 # Refactored some code in the CGI code.
1362 # Revision 1.20  2001/08/12 06:32:36  richard
1363 # using isinstance(blah, Foo) now instead of isFooType
1365 # Revision 1.19  2001/08/07 00:24:42  richard
1366 # stupid typo
1368 # Revision 1.18  2001/08/07 00:15:51  richard
1369 # Added the copyright/license notice to (nearly) all files at request of
1370 # Bizar Software.
1372 # Revision 1.17  2001/08/02 06:38:17  richard
1373 # Roundupdb now appends "mailing list" information to its messages which
1374 # include the e-mail address and web interface address. Templates may
1375 # override this in their db classes to include specific information (support
1376 # instructions, etc).
1378 # Revision 1.16  2001/08/02 05:55:25  richard
1379 # Web edit messages aren't sent to the person who did the edit any more. No
1380 # message is generated if they are the only person on the nosy list.
1382 # Revision 1.15  2001/08/02 00:34:10  richard
1383 # bleah syntax error
1385 # Revision 1.14  2001/08/02 00:26:16  richard
1386 # Changed the order of the information in the message generated by web edits.
1388 # Revision 1.13  2001/07/30 08:12:17  richard
1389 # Added time logging and file uploading to the templates.
1391 # Revision 1.12  2001/07/30 06:26:31  richard
1392 # Added some documentation on how the newblah works.
1394 # Revision 1.11  2001/07/30 06:17:45  richard
1395 # Features:
1396 #  . Added ability for cgi newblah forms to indicate that the new node
1397 #    should be linked somewhere.
1398 # Fixed:
1399 #  . Fixed the agument handling for the roundup-admin find command.
1400 #  . Fixed handling of summary when no note supplied for newblah. Again.
1401 #  . Fixed detection of no form in htmltemplate Field display.
1403 # Revision 1.10  2001/07/30 02:37:34  richard
1404 # Temporary measure until we have decent schema migration...
1406 # Revision 1.9  2001/07/30 01:25:07  richard
1407 # Default implementation is now "classic" rather than "extended" as one would
1408 # expect.
1410 # Revision 1.8  2001/07/29 08:27:40  richard
1411 # Fixed handling of passed-in values in form elements (ie. during a
1412 # drill-down)
1414 # Revision 1.7  2001/07/29 07:01:39  richard
1415 # Added vim command to all source so that we don't get no steenkin' tabs :)
1417 # Revision 1.6  2001/07/29 04:04:00  richard
1418 # Moved some code around allowing for subclassing to change behaviour.
1420 # Revision 1.5  2001/07/28 08:16:52  richard
1421 # New issue form handles lack of note better now.
1423 # Revision 1.4  2001/07/28 00:34:34  richard
1424 # Fixed some non-string node ids.
1426 # Revision 1.3  2001/07/23 03:56:30  richard
1427 # oops, missed a config removal
1429 # Revision 1.2  2001/07/22 12:09:32  richard
1430 # Final commit of Grande Splite
1432 # Revision 1.1  2001/07/22 11:58:35  richard
1433 # More Grande Splite
1436 # vim: set filetype=python ts=4 sw=4 et si