Code

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