Code

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