Code

cleaned up structure
[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.140 2002-07-14 23:17:15 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
25 import binascii, Cookie, time, random
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.
48     Once a user logs in, they are assigned a session. The Client instance
49     keeps the nodeid of the session as the "session" attribute.
50     '''
52     def __init__(self, instance, request, env, form=None):
53         hyperdb.traceMark()
54         self.instance = instance
55         self.request = request
56         self.env = env
57         self.path = env['PATH_INFO']
58         self.split_path = self.path.split('/')
59         self.instance_path_name = env['INSTANCE_NAME']
60         url = self.env['SCRIPT_NAME'] + '/'
61         machine = self.env['SERVER_NAME']
62         port = self.env['SERVER_PORT']
63         if port != '80': machine = machine + ':' + port
64         self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
65             None, None, None))
67         if form is None:
68             self.form = cgi.FieldStorage(environ=env)
69         else:
70             self.form = form
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         try:
80             return self.db.user.lookup(self.user)
81         except KeyError:
82             if self.user is None:
83                 # user is not logged in and username 'anonymous' doesn't
84                 # exist in the database
85                 err = _('anonymous users have read-only access only')
86             else:
87                 err = _("sanity check: unknown user name `%s'")%self.user
88             raise Unauthorised, errmsg
90     def header(self, headers=None):
91         '''Put up the appropriate header.
92         '''
93         if headers is None:
94             headers = {'Content-Type':'text/html'}
95         if not headers.has_key('Content-Type'):
96             headers['Content-Type'] = 'text/html'
97         self.request.send_response(200)
98         for entry in headers.items():
99             self.request.send_header(*entry)
100         self.request.end_headers()
101         self.headers_done = 1
102         if self.debug:
103             self.headers_sent = headers
105     global_javascript = '''
106 <script language="javascript">
107 submitted = false;
108 function submit_once() {
109     if (submitted) {
110         alert("Your request is being processed.\\nPlease be patient.");
111         return 0;
112     }
113     submitted = true;
114     return 1;
117 function help_window(helpurl, width, height) {
118     HelpWin = window.open('%(base)s%(instance_path_name)s/' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
121 </script>
122 '''
123     def make_index_link(self, name):
124         '''Turn a configuration entry into a hyperlink...
125         '''
126         # get the link label and spec
127         spec = getattr(self.instance, name+'_INDEX')
129         d = {}
130         d[':sort'] = ','.join(map(urllib.quote, spec['SORT']))
131         d[':group'] = ','.join(map(urllib.quote, spec['GROUP']))
132         d[':filter'] = ','.join(map(urllib.quote, spec['FILTER']))
133         d[':columns'] = ','.join(map(urllib.quote, spec['COLUMNS']))
134         d[':pagesize'] = spec.get('PAGESIZE','50')
136         # snarf the filterspec
137         filterspec = spec['FILTERSPEC'].copy()
139         # now format the filterspec
140         for k, l in filterspec.items():
141             # fix up the CURRENT USER if needed (handle None too since that's
142             # the old flag value)
143             if l in (None, 'CURRENT USER'):
144                 if not self.user:
145                     continue
146                 l = [self.db.user.lookup(self.user)]
148             # add
149             d[urllib.quote(k)] = ','.join(map(urllib.quote, l))
151         # finally, format the URL
152         return '<a href="%s?%s">%s</a>'%(spec['CLASS'],
153             '&'.join([k+'='+v for k,v in d.items()]), spec['LABEL'])
156     def pagehead(self, title, message=None):
157         '''Display the page heading, with information about the tracker and
158             links to more information
159         '''
161         # include any important message
162         if message is not None:
163             message = _('<div class="system-msg">%(message)s</div>')%locals()
164         else:
165             message = ''
167         # style sheet (CSS)
168         style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
170         # figure who the user is
171         user_name = self.user or ''
172         if user_name not in ('', 'anonymous'):
173             userid = self.db.user.lookup(self.user)
174         else:
175             userid = None
177         # figure all the header links
178         if hasattr(self.instance, 'HEADER_INDEX_LINKS'):
179             links = []
180             for name in self.instance.HEADER_INDEX_LINKS:
181                 spec = getattr(self.instance, name + '_INDEX')
182                 # skip if we need to fill in the logged-in user id there's
183                 # no user logged in
184                 if (spec['FILTERSPEC'].has_key('assignedto') and
185                         spec['FILTERSPEC']['assignedto'] in ('CURRENT USER',
186                         None) and userid is None):
187                     continue
188                 links.append(self.make_index_link(name))
189         else:
190             # no config spec - hard-code
191             links = [
192                 _('All <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>'),
193                 _('Unassigned <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>')
194             ]
196         # if they're logged in, include links to their information, and the
197         # ability to add an issue
198         if user_name not in ('', 'anonymous'):
199             user_info = _('''
200 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
201 ''')%locals()
203             # figure the "add class" links
204             if hasattr(self.instance, 'HEADER_ADD_LINKS'):
205                 classes = self.instance.HEADER_ADD_LINKS
206             else:
207                 classes = ['issue']
208             l = []
209             for class_name in classes:
210                 cap_class = class_name.capitalize()
211                 links.append(_('Add <a href="new%(class_name)s">'
212                     '%(cap_class)s</a>')%locals())
214             # if there's no config header link spec, force a user link here
215             if not hasattr(self.instance, 'HEADER_INDEX_LINKS'):
216                 links.append(_('<a href="issue?assignedto=%(userid)s&status=-1,unread,chatting,open,pending&:filter=status,resolution,assignedto&:sort=-activity&:columns=id,activity,status,resolution,title,creator&:group=type&show_customization=1">My Issues</a>')%locals())
217         else:
218             user_info = _('<a href="login">Login</a>')
219             add_links = ''
221         # if the user is admin, include admin links
222         admin_links = ''
223         if user_name == 'admin':
224             links.append(_('<a href="list_classes">Class List</a>'))
225             links.append(_('<a href="user">User List</a>'))
226             links.append(_('<a href="newuser">Add User</a>'))
228         # add the search links
229         if hasattr(self.instance, 'HEADER_SEARCH_LINKS'):
230             classes = self.instance.HEADER_SEARCH_LINKS
231         else:
232             classes = ['issue']
233         l = []
234         for class_name in classes:
235             cap_class = class_name.capitalize()
236             links.append(_('Search <a href="search%(class_name)s">'
237                 '%(cap_class)s</a>')%locals())
239         # now we have all the links, join 'em
240         links = '\n | '.join(links)
242         # include the javascript bit
243         global_javascript = self.global_javascript%self.__dict__
245         # finally, format the header
246         self.write(_('''<html><head>
247 <title>%(title)s</title>
248 <style type="text/css">%(style)s</style>
249 </head>
250 %(global_javascript)s
251 <body bgcolor=#ffffff>
252 %(message)s
253 <table width=100%% border=0 cellspacing=0 cellpadding=2>
254 <tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
255 <td align=right valign=bottom>%(user_name)s</td></tr>
256 <tr class="location-bar">
257 <td align=left>%(links)s</td>
258 <td align=right>%(user_info)s</td>
259 </table><br>
260 ''')%locals())
262     def pagefoot(self):
263         if self.debug:
264             self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
265             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
266             keys = self.form.keys()
267             keys.sort()
268             if keys:
269                 self.write(_('<dt><b>Form entries</b></dt>'))
270                 for k in self.form.keys():
271                     v = self.form.getvalue(k, "<empty>")
272                     if type(v) is type([]):
273                         # Multiple username fields specified
274                         v = "|".join(v)
275                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
276             keys = self.headers_sent.keys()
277             keys.sort()
278             self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
279             for k in keys:
280                 v = self.headers_sent[k]
281                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
282             keys = self.env.keys()
283             keys.sort()
284             self.write(_('<dt><b>CGI environment</b></dt>'))
285             for k in keys:
286                 v = self.env[k]
287                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
288             self.write('</dl></small>')
289         self.write('</body></html>')
291     def write(self, content):
292         if not self.headers_done:
293             self.header()
294         self.request.wfile.write(content)
296     def index_arg(self, arg):
297         ''' handle the args to index - they might be a list from the form
298             (ie. submitted from a form) or they might be a command-separated
299             single string (ie. manually constructed GET args)
300         '''
301         if self.form.has_key(arg):
302             arg =  self.form[arg]
303             if type(arg) == type([]):
304                 return [arg.value for arg in arg]
305             return arg.value.split(',')
306         return []
308     def index_sort(self):
309         # first try query string
310         x = self.index_arg(':sort')
311         if x:
312             return x
313         # nope - get the specs out of the form
314         specs = []
315         for colnm in self.db.getclass(self.classname).getprops().keys():
316             desc = ''
317             try:
318                 spec = self.form[':%s_ss' % colnm]
319             except KeyError:
320                 continue
321             spec = spec.value
322             if spec:
323                 if spec[-1] == '-':
324                     desc='-'
325                     spec = spec[0]
326                 specs.append((int(spec), colnm, desc))
327         specs.sort()
328         x = []
329         for _, colnm, desc in specs:
330             x.append('%s%s' % (desc, colnm))
331         return x
332     
333     def index_filterspec(self, filter):
334         ''' pull the index filter spec from the form
336         Links and multilinks want to be lists - the rest are straight
337         strings.
338         '''
339         filterspec = {}
340         props = self.db.classes[self.classname].getprops()
341         for colnm in filter:
342             widget = ':%s_fs' % colnm
343             try:
344                 val = self.form[widget]
345             except KeyError:
346                 try:
347                     val = self.form[colnm]
348                 except KeyError:
349                     # they checked the filter box but didn't enter a value
350                     continue
351             propdescr = props.get(colnm, None)
352             if propdescr is None:
353                 print "huh? %r is in filter & form, but not in Class!" % colnm
354                 raise "butthead programmer"
355             if (isinstance(propdescr, hyperdb.Link) or
356                 isinstance(propdescr, hyperdb.Multilink)):
357                 if type(val) == type([]):
358                     val = [arg.value for arg in val]
359                 else:
360                     val = val.value.split(',')
361                 l = filterspec.get(colnm, [])
362                 l = l + val
363                 filterspec[colnm] = l
364             else:
365                 filterspec[colnm] = val.value
366             
367         return filterspec
368     
369     def customization_widget(self):
370         ''' The customization widget is visible by default. The widget
371             visibility is remembered by show_customization.  Visibility
372             is not toggled if the action value is "Redisplay"
373         '''
374         if not self.form.has_key('show_customization'):
375             visible = 1
376         else:
377             visible = int(self.form['show_customization'].value)
378             if self.form.has_key('action'):
379                 if self.form['action'].value != 'Redisplay':
380                     visible = self.form['action'].value == '+'
381             
382         return visible
384     # TODO: make this go away some day...
385     default_index_sort = ['-activity']
386     default_index_group = ['priority']
387     default_index_filter = ['status']
388     default_index_columns = ['id','activity','title','status','assignedto']
389     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
390     default_pagesize = '50'
392     def _get_customisation_info(self):
393         # see if the web has supplied us with any customisation info
394         for key in ':sort', ':group', ':filter', ':columns', ':pagesize':
395             if self.form.has_key(key):
396                 # make list() extract the info from the CGI environ
397                 self.classname = 'issue'
398                 sort = group = filter = columns = filterspec = pagesize = None
399                 break
400         else:
401             # TODO: look up the session first
402             # try the instance config first
403             if hasattr(self.instance, 'DEFAULT_INDEX'):
404                 d = self.instance.DEFAULT_INDEX
405                 self.classname = d['CLASS']
406                 sort = d['SORT']
407                 group = d['GROUP']
408                 filter = d['FILTER']
409                 columns = d['COLUMNS']
410                 filterspec = d['FILTERSPEC']
411                 pagesize = d.get('PAGESIZE', '50')
412             else:
413                 # nope - fall back on the old way of doing it
414                 self.classname = 'issue'
415                 sort = self.default_index_sort
416                 group = self.default_index_group
417                 filter = self.default_index_filter
418                 columns = self.default_index_columns
419                 filterspec = self.default_index_filterspec
420                 pagesize = self.default_pagesize
421         return columns, filter, group, sort, filterspec, pagesize
423     def index(self):
424         ''' put up an index - no class specified
425         '''
426         columns, filter, group, sort, filterspec, pagesize = \
427             self._get_customisation_info()
428         return self.list(columns=columns, filter=filter, group=group,
429             sort=sort, filterspec=filterspec, pagesize=pagesize)
431     def searchnode(self):
432         columns, filter, group, sort, filterspec, pagesize = \
433             self._get_customisation_info()
434         cn = self.classname
435         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
436             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
437         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
438         self.write('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
439         all_columns = self.db.getclass(cn).getprops().keys()
440         all_columns.sort()
441         index.filter_section('', filter, columns, group, all_columns, sort,
442                              filterspec, pagesize, 0)
443         self.pagefoot()
444         index.db = index.cl = index.properties = None
445         index.clear()
447     # XXX deviates from spec - loses the '+' (that's a reserved character
448     # in URLS
449     def list(self, sort=None, group=None, filter=None, columns=None,
450             filterspec=None, show_customization=None, show_nodes=1, pagesize=None):
451         ''' call the template index with the args
453             :sort    - sort by prop name, optionally preceeded with '-'
454                      to give descending or nothing for ascending sorting.
455             :group   - group by prop name, optionally preceeded with '-' or
456                      to sort in descending or nothing for ascending order.
457             :filter  - selects which props should be displayed in the filter
458                      section. Default is all.
459             :columns - selects the columns that should be displayed.
460                      Default is all.
462         '''
463         cn = self.classname
464         cl = self.db.classes[cn]
465         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
466             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
467         if sort is None: sort = self.index_sort()
468         if group is None: group = self.index_arg(':group')
469         if filter is None: filter = self.index_arg(':filter')
470         if columns is None: columns = self.index_arg(':columns')
471         if filterspec is None: filterspec = self.index_filterspec(filter)
472         if show_customization is None:
473             show_customization = self.customization_widget()
474         if self.form.has_key('search_text'):
475             search_text = self.form['search_text'].value
476         else:
477             search_text = ''
478         if pagesize is None:
479             if self.form.has_key(':pagesize'):
480                 pagesize = self.form[':pagesize'].value
481             else:
482                 pagesize = '50'
483         pagesize = int(pagesize)
484         if self.form.has_key(':startwith'):
485             startwith = int(self.form[':startwith'].value)
486         else:
487             startwith = 0
489         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
490         try:
491             index.render(filterspec, search_text, filter, columns, sort, 
492                 group, show_customization=show_customization, 
493                 show_nodes=show_nodes, pagesize=pagesize, startwith=startwith)
494         except htmltemplate.MissingTemplateError:
495             self.basicClassEditPage()
496         self.pagefoot()
498     def basicClassEditPage(self):
499         '''Display a basic edit page that allows simple editing of the
500            nodes of the current class
501         '''
502         if self.user != 'admin':
503             raise Unauthorised
504         w = self.write
505         cn = self.classname
506         cl = self.db.classes[cn]
507         idlessprops = cl.getprops(protected=0).keys()
508         props = ['id'] + idlessprops
511         # get the CSV module
512         try:
513             import csv
514         except ImportError:
515             w(_('Sorry, you need the csv module to use this function.<br>\n'
516                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
517             return
519         # do the edit
520         if self.form.has_key('rows'):
521             rows = self.form['rows'].value.splitlines()
522             p = csv.parser()
523             found = {}
524             line = 0
525             for row in rows:
526                 line += 1
527                 values = p.parse(row)
528                 # not a complete row, keep going
529                 if not values: continue
531                 # extract the nodeid
532                 nodeid, values = values[0], values[1:]
533                 found[nodeid] = 1
535                 # confirm correct weight
536                 if len(idlessprops) != len(values):
537                     w(_('Not enough values on line %(line)s'%{'line':line}))
538                     return
540                 # extract the new values
541                 d = {}
542                 for name, value in zip(idlessprops, values):
543                     d[name] = value.strip()
545                 # perform the edit
546                 if cl.hasnode(nodeid):
547                     # edit existing
548                     cl.set(nodeid, **d)
549                 else:
550                     # new node
551                     found[cl.create(**d)] = 1
553             # retire the removed entries
554             for nodeid in cl.list():
555                 if not found.has_key(nodeid):
556                     cl.retire(nodeid)
558         w(_('''<p class="form-help">You may edit the contents of the
559         "%(classname)s" class using this form. The lines are full-featured
560         Comma-Separated-Value lines, so you may include commas and even
561         newlines by enclosing the values in double-quotes ("). Double
562         quotes themselves must be quoted by doubling ("").</p>
563         <p class="form-help">Remove entries by deleting their line. Add
564         new entries by appending
565         them to the table - put an X in the id column.</p>''')%{'classname':cn})
567         l = []
568         for name in props:
569             l.append(name)
570         w('<tt>')
571         w(', '.join(l) + '\n')
572         w('</tt>')
574         w('<form onSubmit="return submit_once()" method="POST">')
575         w('<textarea name="rows" cols=80 rows=15>')
576         p = csv.parser()
577         for nodeid in cl.list():
578             l = []
579             for name in props:
580                 l.append(cgi.escape(str(cl.get(nodeid, name))))
581             w(p.join(l) + '\n')
583         w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
585     def classhelp(self):
586         '''Display a table of class info
587         '''
588         w = self.write
589         cn = self.form['classname'].value
590         cl = self.db.classes[cn]
591         props = self.form['properties'].value.split(',')
592         if cl.labelprop(1) in props:
593             sort = [cl.labelprop(1)]
594         else:
595             sort = props[0]
597         w('<table border=1 cellspacing=0 cellpaddin=2>')
598         w('<tr>')
599         for name in props:
600             w('<th align=left>%s</th>'%name)
601         w('</tr>')
602         for nodeid in cl.filter(None, {}, sort, []): #cl.list():
603             w('<tr>')
604             for name in props:
605                 value = cgi.escape(str(cl.get(nodeid, name)))
606                 w('<td align="left" valign="top">%s</td>'%value)
607             w('</tr>')
608         w('</table>')
610     def shownode(self, message=None, num_re=re.compile('^\d+$')):
611         ''' display an item
612         '''
613         cn = self.classname
614         cl = self.db.classes[cn]
615         if self.form.has_key(':multilink'):
616             link = self.form[':multilink'].value
617             designator, linkprop = link.split(':')
618             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
619         else:
620             xtra = ''
622         # possibly perform an edit
623         keys = self.form.keys()
624         # don't try to set properties if the user has just logged in
625         if keys and not self.form.has_key('__login_name'):
626             try:
627                 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
628                 # make changes to the node
629                 self._changenode(props)
630                 # handle linked nodes 
631                 self._post_editnode(self.nodeid)
632                 # and some nice feedback for the user
633                 if props:
634                     message = _('%(changes)s edited ok')%{'changes':
635                         ', '.join(props.keys())}
636                 elif self.form.has_key('__note') and self.form['__note'].value:
637                     message = _('note added')
638                 elif (self.form.has_key('__file') and
639                         self.form['__file'].filename):
640                     message = _('file added')
641                 else:
642                     message = _('nothing changed')
643             except:
644                 self.db.rollback()
645                 s = StringIO.StringIO()
646                 traceback.print_exc(None, s)
647                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
649         # now the display
650         id = self.nodeid
651         if cl.getkey():
652             id = cl.get(id, cl.getkey())
653         self.pagehead('%s: %s %s'%(self.classname.capitalize(), id, xtra),
654             message)
656         nodeid = self.nodeid
658         # use the template to display the item
659         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
660             self.classname)
661         item.render(nodeid)
663         self.pagefoot()
664     showissue = shownode
665     showmsg = shownode
666     searchissue = searchnode
668     def _changenode(self, props):
669         ''' change the node based on the contents of the form
670         '''
671         cl = self.db.classes[self.classname]
673         # create the message
674         message, files = self._handle_message()
675         if message:
676             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
677         if files:
678             props['files'] = cl.get(self.nodeid, 'files') + files
680         # make the changes
681         cl.set(self.nodeid, **props)
683     def _createnode(self):
684         ''' create a node based on the contents of the form
685         '''
686         cl = self.db.classes[self.classname]
687         props = parsePropsFromForm(self.db, cl, self.form)
689         # check for messages and files
690         message, files = self._handle_message()
691         if message:
692             props['messages'] = [message]
693         if files:
694             props['files'] = files
695         # create the node and return it's id
696         return cl.create(**props)
698     def _handle_message(self):
699         ''' generate an edit message
700         '''
701         # handle file attachments 
702         files = []
703         if self.form.has_key('__file'):
704             file = self.form['__file']
705             if file.filename:
706                 filename = file.filename.split('\\')[-1]
707                 mime_type = mimetypes.guess_type(filename)[0]
708                 if not mime_type:
709                     mime_type = "application/octet-stream"
710                 # create the new file entry
711                 files.append(self.db.file.create(type=mime_type,
712                     name=filename, content=file.file.read()))
714         # we don't want to do a message if none of the following is true...
715         cn = self.classname
716         cl = self.db.classes[self.classname]
717         props = cl.getprops()
718         note = None
719         # in a nutshell, don't do anything if there's no note or there's no
720         # NOSY
721         if self.form.has_key('__note'):
722             note = self.form['__note'].value.strip()
723         if not note:
724             return None, files
725         if not props.has_key('messages'):
726             return None, files
727         if not isinstance(props['messages'], hyperdb.Multilink):
728             return None, files
729         if not props['messages'].classname == 'msg':
730             return None, files
731         if not (self.form.has_key('nosy') or note):
732             return None, files
734         # handle the note
735         if '\n' in note:
736             summary = re.split(r'\n\r?', note)[0]
737         else:
738             summary = note
739         m = ['%s\n'%note]
741         # handle the messageid
742         # TODO: handle inreplyto
743         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
744             self.classname, self.instance.MAIL_DOMAIN)
746         # now create the message, attaching the files
747         content = '\n'.join(m)
748         message_id = self.db.msg.create(author=self.getuid(),
749             recipients=[], date=date.Date('.'), summary=summary,
750             content=content, files=files, messageid=messageid)
752         # update the messages property
753         return message_id, files
755     def _post_editnode(self, nid):
756         '''Do the linking part of the node creation.
758            If a form element has :link or :multilink appended to it, its
759            value specifies a node designator and the property on that node
760            to add _this_ node to as a link or multilink.
762            This is typically used on, eg. the file upload page to indicated
763            which issue to link the file to.
765            TODO: I suspect that this and newfile will go away now that
766            there's the ability to upload a file using the issue __file form
767            element!
768         '''
769         cn = self.classname
770         cl = self.db.classes[cn]
771         # link if necessary
772         keys = self.form.keys()
773         for key in keys:
774             if key == ':multilink':
775                 value = self.form[key].value
776                 if type(value) != type([]): value = [value]
777                 for value in value:
778                     designator, property = value.split(':')
779                     link, nodeid = roundupdb.splitDesignator(designator)
780                     link = self.db.classes[link]
781                     # take a dupe of the list so we're not changing the cache
782                     value = link.get(nodeid, property)[:]
783                     value.append(nid)
784                     link.set(nodeid, **{property: value})
785             elif key == ':link':
786                 value = self.form[key].value
787                 if type(value) != type([]): value = [value]
788                 for value in value:
789                     designator, property = value.split(':')
790                     link, nodeid = roundupdb.splitDesignator(designator)
791                     link = self.db.classes[link]
792                     link.set(nodeid, **{property: nid})
794     def newnode(self, message=None):
795         ''' Add a new node to the database.
796         
797         The form works in two modes: blank form and submission (that is,
798         the submission goes to the same URL). **Eventually this means that
799         the form will have previously entered information in it if
800         submission fails.
802         The new node will be created with the properties specified in the
803         form submission. For multilinks, multiple form entries are handled,
804         as are prop=value,value,value. You can't mix them though.
806         If the new node is to be referenced from somewhere else immediately
807         (ie. the new node is a file that is to be attached to a support
808         issue) then supply one of these arguments in addition to the usual
809         form entries:
810             :link=designator:property
811             :multilink=designator:property
812         ... which means that once the new node is created, the "property"
813         on the node given by "designator" should now reference the new
814         node's id. The node id will be appended to the multilink.
815         '''
816         cn = self.classname
817         cl = self.db.classes[cn]
818         if self.form.has_key(':multilink'):
819             link = self.form[':multilink'].value
820             designator, linkprop = link.split(':')
821             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
822         else:
823             xtra = ''
825         # possibly perform a create
826         keys = self.form.keys()
827         if [i for i in keys if i[0] != ':']:
828             props = {}
829             try:
830                 nid = self._createnode()
831                 # handle linked nodes 
832                 self._post_editnode(nid)
833                 # and some nice feedback for the user
834                 message = _('%(classname)s created ok')%{'classname': cn}
836                 # render the newly created issue
837                 self.db.commit()
838                 self.nodeid = nid
839                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
840                     message)
841                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
842                     self.classname)
843                 item.render(nid)
844                 self.pagefoot()
845                 return
846             except:
847                 self.db.rollback()
848                 s = StringIO.StringIO()
849                 traceback.print_exc(None, s)
850                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
851         self.pagehead(_('New %(classname)s %(xtra)s')%{
852                 'classname': self.classname.capitalize(),
853                 'xtra': xtra }, message)
855         # call the template
856         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
857             self.classname)
858         newitem.render(self.form)
860         self.pagefoot()
861     newissue = newnode
863     def newuser(self, message=None):
864         ''' Add a new user to the database.
866             Don't do any of the message or file handling, just create the node.
867         '''
868         cn = self.classname
869         cl = self.db.classes[cn]
871         # possibly perform a create
872         keys = self.form.keys()
873         if [i for i in keys if i[0] != ':']:
874             try:
875                 props = parsePropsFromForm(self.db, cl, self.form)
876                 nid = cl.create(**props)
877                 # handle linked nodes 
878                 self._post_editnode(nid)
879                 # and some nice feedback for the user
880                 message = _('%(classname)s created ok')%{'classname': cn}
881             except:
882                 self.db.rollback()
883                 s = StringIO.StringIO()
884                 traceback.print_exc(None, s)
885                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
886         self.pagehead(_('New %(classname)s')%{'classname':
887              self.classname.capitalize()}, message)
889         # call the template
890         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
891             self.classname)
892         newitem.render(self.form)
894         self.pagefoot()
896     def newfile(self, message=None):
897         ''' Add a new file to the database.
898         
899         This form works very much the same way as newnode - it just has a
900         file upload.
901         '''
902         cn = self.classname
903         cl = self.db.classes[cn]
904         props = parsePropsFromForm(self.db, cl, self.form)
905         if self.form.has_key(':multilink'):
906             link = self.form[':multilink'].value
907             designator, linkprop = link.split(':')
908             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
909         else:
910             xtra = ''
912         # possibly perform a create
913         keys = self.form.keys()
914         if [i for i in keys if i[0] != ':']:
915             try:
916                 file = self.form['content']
917                 mime_type = mimetypes.guess_type(file.filename)[0]
918                 if not mime_type:
919                     mime_type = "application/octet-stream"
920                 # save the file
921                 props['type'] = mime_type
922                 props['name'] = file.filename
923                 props['content'] = file.file.read()
924                 nid = cl.create(**props)
925                 # handle linked nodes
926                 self._post_editnode(nid)
927                 # and some nice feedback for the user
928                 message = _('%(classname)s created ok')%{'classname': cn}
929             except:
930                 self.db.rollback()
931                 s = StringIO.StringIO()
932                 traceback.print_exc(None, s)
933                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
935         self.pagehead(_('New %(classname)s %(xtra)s')%{
936                 'classname': self.classname.capitalize(),
937                 'xtra': xtra }, message)
938         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
939             self.classname)
940         newitem.render(self.form)
941         self.pagefoot()
943     def showuser(self, message=None, num_re=re.compile('^\d+$')):
944         '''Display a user page for editing. Make sure the user is allowed
945             to edit this node, and also check for password changes.
946         '''
947         if self.user == 'anonymous':
948             raise Unauthorised
950         user = self.db.user
952         # get the username of the node being edited
953         node_user = user.get(self.nodeid, 'username')
955         if self.user not in ('admin', node_user):
956             raise Unauthorised
958         #
959         # perform any editing
960         #
961         keys = self.form.keys()
962         if keys:
963             try:
964                 props = parsePropsFromForm(self.db, user, self.form,
965                     self.nodeid)
966                 set_cookie = 0
967                 if props.has_key('password'):
968                     password = self.form['password'].value.strip()
969                     if not password:
970                         # no password was supplied - don't change it
971                         del props['password']
972                     elif self.nodeid == self.getuid():
973                         # this is the logged-in user's password
974                         set_cookie = password
975                 user.set(self.nodeid, **props)
976                 # and some feedback for the user
977                 message = _('%(changes)s edited ok')%{'changes':
978                     ', '.join(props.keys())}
979             except:
980                 self.db.rollback()
981                 s = StringIO.StringIO()
982                 traceback.print_exc(None, s)
983                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
984         else:
985             set_cookie = 0
987         # fix the cookie if the password has changed
988         if set_cookie:
989             self.set_cookie(self.user, set_cookie)
991         #
992         # now the display
993         #
994         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
996         # use the template to display the item
997         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
998         item.render(self.nodeid)
999         self.pagefoot()
1001     def showfile(self):
1002         ''' display a file
1003         '''
1004         nodeid = self.nodeid
1005         cl = self.db.classes[self.classname]
1006         mime_type = cl.get(nodeid, 'type')
1007         if mime_type == 'message/rfc822':
1008             mime_type = 'text/plain'
1009         self.header(headers={'Content-Type': mime_type})
1010         self.write(cl.get(nodeid, 'content'))
1012     def classes(self, message=None):
1013         ''' display a list of all the classes in the database
1014         '''
1015         if self.user == 'admin':
1016             self.pagehead(_('Table of classes'), message)
1017             classnames = self.db.classes.keys()
1018             classnames.sort()
1019             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
1020             for cn in classnames:
1021                 cl = self.db.getclass(cn)
1022                 self.write('<tr class="list-header"><th colspan=2 align=left>'
1023                     '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
1024                 for key, value in cl.properties.items():
1025                     if value is None: value = ''
1026                     else: value = str(value)
1027                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
1028                         key, cgi.escape(value)))
1029             self.write('</table>')
1030             self.pagefoot()
1031         else:
1032             raise Unauthorised
1034     def login(self, message=None, newuser_form=None, action='index'):
1035         '''Display a login page.
1036         '''
1037         self.pagehead(_('Login to roundup'), message)
1038         self.write(_('''
1039 <table>
1040 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
1041 <form onSubmit="return submit_once()" action="login_action" method=POST>
1042 <input type="hidden" name="__destination_url" value="%(action)s">
1043 <tr><td align=right>Login name: </td>
1044     <td><input name="__login_name"></td></tr>
1045 <tr><td align=right>Password: </td>
1046     <td><input type="password" name="__login_password"></td></tr>
1047 <tr><td></td>
1048     <td><input type="submit" value="Log In"></td></tr>
1049 </form>
1050 ''')%locals())
1051         if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
1052             self.write('</table>')
1053             self.pagefoot()
1054             return
1055         values = {'realname': '', 'organisation': '', 'address': '',
1056             'phone': '', 'username': '', 'password': '', 'confirm': '',
1057             'action': action, 'alternate_addresses': ''}
1058         if newuser_form is not None:
1059             for key in newuser_form.keys():
1060                 values[key] = newuser_form[key].value
1061         self.write(_('''
1062 <p>
1063 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1064 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1065 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1066 <input type="hidden" name="__destination_url" value="%(action)s">
1067 <tr><td align=right><em>Name: </em></td>
1068     <td><input name="realname" value="%(realname)s" size=40></td></tr>
1069 <tr><td align=right><em>Organisation: </em></td>
1070     <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1071 <tr><td align=right>E-Mail Address: </td>
1072     <td><input name="address" value="%(address)s" size=40></td></tr>
1073 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1074     <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1075 <tr><td align=right><em>Phone: </em></td>
1076     <td><input name="phone" value="%(phone)s"></td></tr>
1077 <tr><td align=right>Preferred Login name: </td>
1078     <td><input name="username" value="%(username)s"></td></tr>
1079 <tr><td align=right>Password: </td>
1080     <td><input type="password" name="password" value="%(password)s"></td></tr>
1081 <tr><td align=right>Password Again: </td>
1082     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1083 <tr><td></td>
1084     <td><input type="submit" value="Register"></td></tr>
1085 </form>
1086 </table>
1087 ''')%values)
1088         self.pagefoot()
1090     def login_action(self, message=None):
1091         '''Attempt to log a user in and set the cookie
1093         returns 0 if a page is generated as a result of this call, and
1094         1 if not (ie. the login is successful
1095         '''
1096         if not self.form.has_key('__login_name'):
1097             self.login(message=_('Username required'))
1098             return 0
1099         self.user = self.form['__login_name'].value
1100         # re-open the database for real, using the user
1101         self.opendb(self.user)
1102         if self.form.has_key('__login_password'):
1103             password = self.form['__login_password'].value
1104         else:
1105             password = ''
1106         # make sure the user exists
1107         try:
1108             uid = self.db.user.lookup(self.user)
1109         except KeyError:
1110             name = self.user
1111             self.make_user_anonymous()
1112             action = self.form['__destination_url'].value
1113             self.login(message=_('No such user "%(name)s"')%locals(),
1114                 action=action)
1115             return 0
1117         # and that the password is correct
1118         pw = self.db.user.get(uid, 'password')
1119         if password != pw:
1120             self.make_user_anonymous()
1121             action = self.form['__destination_url'].value
1122             self.login(message=_('Incorrect password'), action=action)
1123             return 0
1125         self.set_cookie(self.user, password)
1126         return 1
1128     def newuser_action(self, message=None):
1129         '''Attempt to create a new user based on the contents of the form
1130         and then set the cookie.
1132         return 1 on successful login
1133         '''
1134         # re-open the database as "admin"
1135         self.opendb('admin')
1137         # create the new user
1138         cl = self.db.user
1139         try:
1140             props = parsePropsFromForm(self.db, cl, self.form)
1141             uid = cl.create(**props)
1142         except ValueError, message:
1143             action = self.form['__destination_url'].value
1144             self.login(message, action=action)
1145             return 0
1147         # log the new user in
1148         self.user = cl.get(uid, 'username')
1149         # re-open the database for real, using the user
1150         self.opendb(self.user)
1151         password = cl.get(uid, 'password')
1152         self.set_cookie(self.user, self.form['password'].value)
1153         return 1
1155     def set_cookie(self, user, password):
1156         # TODO generate a much, much stronger session key ;)
1157         session = binascii.b2a_base64(repr(time.time())).strip()
1159         # clean up the base64
1160         if session[-1] == '=':
1161           if session[-2] == '=':
1162             session = session[:-2]
1163           else:
1164             session = session[:-1]
1166         print 'session set to', `session`
1168         # insert the session in the sessiondb
1169         sessions = self.db.getclass('__sessions')
1170         self.session = sessions.create(sessid=session, user=user,
1171             last_use=date.Date())
1173         # and commit immediately
1174         self.db.commit()
1176         # expire us in a long, long time
1177         expire = Cookie._getdate(86400*365)
1179         # generate the cookie path - make sure it has a trailing '/'
1180         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1181             ''))
1182         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
1183             session, expire, path)})
1185     def make_user_anonymous(self):
1186         # make us anonymous if we can
1187         try:
1188             self.db.user.lookup('anonymous')
1189             self.user = 'anonymous'
1190         except KeyError:
1191             self.user = None
1193     def logout(self, message=None):
1194         self.make_user_anonymous()
1195         # construct the logout cookie
1196         now = Cookie._getdate()
1197         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1198             ''))
1199         self.header({'Set-Cookie':
1200             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1201             path)})
1202         self.login()
1204     def opendb(self, user):
1205         ''' Open the database - but include the definition of the sessions db.
1206         '''
1207         # open the db
1208         self.db = self.instance.open(user)
1210         # make sure we have the session Class
1211         try:
1212             sessions = self.db.getclass('__sessions')
1213         except:
1214             # add the sessions Class - use a non-journalling Class
1215             # TODO: not happy with how we're getting the Class here :(
1216             sessions = self.instance.dbinit.Class(self.db, '__sessions',
1217                 sessid=hyperdb.String(), user=hyperdb.String(),
1218                 last_use=hyperdb.Date())
1219             sessions.setkey('sessid')
1220             # make sure session db isn't journalled
1221             sessions.disableJournalling()
1223     def main(self):
1224         '''Wrap the database accesses so we can close the database cleanly
1225         '''
1226         # determine the uid to use
1227         self.opendb('admin')
1229         # make sure we have the session Class
1230         sessions = self.db.getclass('__sessions')
1232         # age sessions, remove when they haven't been used for a week
1233         # TODO: this shouldn't be done every access
1234         week = date.Interval('7d')
1235         now = date.Date()
1236         for sessid in sessions.list():
1237             interval = now - sessions.get(sessid, 'last_use')
1238             if interval > week:
1239                 sessions.destroy(sessid)
1241         # look up the user session cookie
1242         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1243         user = 'anonymous'
1244         if (cookie.has_key('roundup_user') and
1245                 cookie['roundup_user'].value != 'deleted'):
1247             # get the session key from the cookie
1248             session = cookie['roundup_user'].value
1250             # get the user from the session
1251             try:
1252                 self.session = sessions.lookup(session)
1253             except KeyError:
1254                 user = 'anonymous'
1255             else:
1256                 # update the lifetime datestamp
1257                 sessions.set(self.session, last_use=date.Date())
1258                 self.db.commit()
1259                 user = sessions.get(sessid, 'user')
1261         # make sure the anonymous user is valid if we're using it
1262         if user == 'anonymous':
1263             self.make_user_anonymous()
1264         else:
1265             self.user = user
1267         # now figure which function to call
1268         path = self.split_path
1270         # default action to index if the path has no information in it
1271         if not path or path[0] in ('', 'index'):
1272             action = 'index'
1273         else:
1274             action = path[0]
1276         # Everthing ignores path[1:]
1277         #  - The file download link generator actually relies on this - it
1278         #    appends the name of the file to the URL so the download file name
1279         #    is correct, but doesn't actually use it.
1281         # everyone is allowed to try to log in
1282         if action == 'login_action':
1283             # try to login
1284             if not self.login_action():
1285                 return
1286             # figure the resulting page
1287             action = self.form['__destination_url'].value
1288             if not action:
1289                 action = 'index'
1290             self.do_action(action)
1291             return
1293         # allow anonymous people to register
1294         if action == 'newuser_action':
1295             # if we don't have a login and anonymous people aren't allowed to
1296             # register, then spit up the login form
1297             if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1298                 if action == 'login':
1299                     self.login()         # go to the index after login
1300                 else:
1301                     self.login(action=action)
1302                 return
1303             # try to add the user
1304             if not self.newuser_action():
1305                 return
1306             # figure the resulting page
1307             action = self.form['__destination_url'].value
1308             if not action:
1309                 action = 'index'
1311         # no login or registration, make sure totally anonymous access is OK
1312         elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1313             if action == 'login':
1314                 self.login()             # go to the index after login
1315             else:
1316                 self.login(action=action)
1317             return
1319         # re-open the database for real, using the user
1320         self.opendb(self.user)
1322         # just a regular action
1323         self.do_action(action)
1325         # commit all changes to the database
1326         self.db.commit()
1328     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1329             nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1330         '''Figure the user's action and do it.
1331         '''
1332         # here be the "normal" functionality
1333         if action == 'index':
1334             self.index()
1335             return
1336         if action == 'list_classes':
1337             self.classes()
1338             return
1339         if action == 'classhelp':
1340             self.classhelp()
1341             return
1342         if action == 'login':
1343             self.login()
1344             return
1345         if action == 'logout':
1346             self.logout()
1347             return
1349         # see if we're to display an existing node
1350         m = dre.match(action)
1351         if m:
1352             self.classname = m.group(1)
1353             self.nodeid = m.group(2)
1354             try:
1355                 cl = self.db.classes[self.classname]
1356             except KeyError:
1357                 raise NotFound, self.classname
1358             try:
1359                 cl.get(self.nodeid, 'id')
1360             except IndexError:
1361                 raise NotFound, self.nodeid
1362             try:
1363                 func = getattr(self, 'show%s'%self.classname)
1364             except AttributeError:
1365                 raise NotFound, 'show%s'%self.classname
1366             func()
1367             return
1369         # see if we're to put up the new node page
1370         m = nre.match(action)
1371         if m:
1372             self.classname = m.group(1)
1373             try:
1374                 func = getattr(self, 'new%s'%self.classname)
1375             except AttributeError:
1376                 raise NotFound, 'new%s'%self.classname
1377             func()
1378             return
1380         # see if we're to put up the new node page
1381         m = sre.match(action)
1382         if m:
1383             self.classname = m.group(1)
1384             try:
1385                 func = getattr(self, 'search%s'%self.classname)
1386             except AttributeError:
1387                 raise NotFound
1388             func()
1389             return
1391         # otherwise, display the named class
1392         self.classname = action
1393         try:
1394             self.db.getclass(self.classname)
1395         except KeyError:
1396             raise NotFound, self.classname
1397         self.list()
1400 class ExtendedClient(Client): 
1401     '''Includes pages and page heading information that relate to the
1402        extended schema.
1403     ''' 
1404     showsupport = Client.shownode
1405     showtimelog = Client.shownode
1406     newsupport = Client.newnode
1407     newtimelog = Client.newnode
1408     searchsupport = Client.searchnode
1410     default_index_sort = ['-activity']
1411     default_index_group = ['priority']
1412     default_index_filter = ['status']
1413     default_index_columns = ['activity','status','title','assignedto']
1414     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1415     default_pagesize = '50'
1417 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1418     '''Pull properties for the given class out of the form.
1419     '''
1420     props = {}
1421     keys = form.keys()
1422     for key in keys:
1423         if not cl.properties.has_key(key):
1424             continue
1425         proptype = cl.properties[key]
1426         if isinstance(proptype, hyperdb.String):
1427             value = form[key].value.strip()
1428         elif isinstance(proptype, hyperdb.Password):
1429             value = password.Password(form[key].value.strip())
1430         elif isinstance(proptype, hyperdb.Date):
1431             value = form[key].value.strip()
1432             if value:
1433                 value = date.Date(form[key].value.strip())
1434             else:
1435                 value = None
1436         elif isinstance(proptype, hyperdb.Interval):
1437             value = form[key].value.strip()
1438             if value:
1439                 value = date.Interval(form[key].value.strip())
1440             else:
1441                 value = None
1442         elif isinstance(proptype, hyperdb.Link):
1443             value = form[key].value.strip()
1444             # see if it's the "no selection" choice
1445             if value == '-1':
1446                 # don't set this property
1447                 continue
1448             else:
1449                 # handle key values
1450                 link = cl.properties[key].classname
1451                 if not num_re.match(value):
1452                     try:
1453                         value = db.classes[link].lookup(value)
1454                     except KeyError:
1455                         raise ValueError, _('property "%(propname)s": '
1456                             '%(value)s not a %(classname)s')%{'propname':key, 
1457                             'value': value, 'classname': link}
1458         elif isinstance(proptype, hyperdb.Multilink):
1459             value = form[key]
1460             if hasattr(value, 'value'):
1461                 # Quite likely to be a FormItem instance
1462                 value = value.value
1463             if not isinstance(value, type([])):
1464                 value = [i.strip() for i in value.split(',')]
1465             else:
1466                 value = [i.strip() for i in value]
1467             link = cl.properties[key].classname
1468             l = []
1469             for entry in map(str, value):
1470                 if entry == '': continue
1471                 if not num_re.match(entry):
1472                     try:
1473                         entry = db.classes[link].lookup(entry)
1474                     except KeyError:
1475                         raise ValueError, _('property "%(propname)s": '
1476                             '"%(value)s" not an entry of %(classname)s')%{
1477                             'propname':key, 'value': entry, 'classname': link}
1478                 l.append(entry)
1479             l.sort()
1480             value = l
1482         # get the old value
1483         if nodeid:
1484             try:
1485                 existing = cl.get(nodeid, key)
1486             except KeyError:
1487                 # this might be a new property for which there is no existing
1488                 # value
1489                 if not cl.properties.has_key(key): raise
1491             # if changed, set it
1492             if value != existing:
1493                 props[key] = value
1494         else:
1495             props[key] = value
1496     return props
1499 # $Log: not supported by cvs2svn $
1500 # Revision 1.139  2002/07/14 06:14:40  richard
1501 # Some more TODOs
1503 # Revision 1.138  2002/07/14 04:03:13  richard
1504 # Implemented a switch to disable journalling for a Class. CGI session
1505 # database now uses it.
1507 # Revision 1.137  2002/07/10 07:00:30  richard
1508 # removed debugging
1510 # Revision 1.136  2002/07/10 06:51:08  richard
1511 # . #576241 ] MultiLink problems in parsePropsFromForm
1513 # Revision 1.135  2002/07/10 00:22:34  richard
1514 #  . switched to using a session-based web login
1516 # Revision 1.134  2002/07/09 04:19:09  richard
1517 # Added reindex command to roundup-admin.
1518 # Fixed reindex on first access.
1519 # Also fixed reindexing of entries that change.
1521 # Revision 1.133  2002/07/08 15:32:05  gmcm
1522 # Pagination of index pages.
1523 # New search form.
1525 # Revision 1.132  2002/07/08 07:26:14  richard
1526 # ehem
1528 # Revision 1.131  2002/07/08 06:53:57  richard
1529 # Not sure why the cgi_client had an indexer argument.
1531 # Revision 1.130  2002/06/27 12:01:53  gmcm
1532 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1533 # Some minor optimizations (only compile regexes once).
1535 # Revision 1.129  2002/06/20 23:52:11  richard
1536 # Better handling of unauth attempt to edit stuff
1538 # Revision 1.128  2002/06/12 21:28:25  gmcm
1539 # Allow form to set user-properties on a Fileclass.
1540 # Don't assume that a Fileclass is named "files".
1542 # Revision 1.127  2002/06/11 06:38:24  richard
1543 #  . #565996 ] The "Attach a File to this Issue" fails
1545 # Revision 1.126  2002/05/29 01:16:17  richard
1546 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1547 # though.
1549 # . #541941 ] changing multilink properties by mail
1550 # . #526730 ] search for messages capability
1551 # . #505180 ] split MailGW.handle_Message
1552 #   - also changed cgi client since it was duplicating the functionality
1553 # . build htmlbase if tests are run using CVS checkout (removed note from
1554 #   installation.txt)
1555 # . don't create an empty message on email issue creation if the email is empty
1557 # Revision 1.125  2002/05/25 07:16:24  rochecompaan
1558 # Merged search_indexing-branch with HEAD
1560 # Revision 1.124  2002/05/24 02:09:24  richard
1561 # Nothing like a live demo to show up the bugs ;)
1563 # Revision 1.123  2002/05/22 05:04:13  richard
1564 # Oops
1566 # Revision 1.122  2002/05/22 04:12:05  richard
1567 #  . applied patch #558876 ] cgi client customization
1568 #    ... with significant additions and modifications ;)
1569 #    - extended handling of ML assignedto to all places it's handled
1570 #    - added more NotFound info
1572 # Revision 1.121  2002/05/21 06:08:10  richard
1573 # Handle migration
1575 # Revision 1.120  2002/05/21 06:05:53  richard
1576 #  . #551483 ] assignedto in Client.make_index_link
1578 # Revision 1.119  2002/05/15 06:21:21  richard
1579 #  . node caching now works, and gives a small boost in performance
1581 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1582 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1583 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1584 # (using if __debug__ which is compiled out with -O)
1586 # Revision 1.118  2002/05/12 23:46:33  richard
1587 # ehem, part 2
1589 # Revision 1.117  2002/05/12 23:42:29  richard
1590 # ehem
1592 # Revision 1.116  2002/05/02 08:07:49  richard
1593 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1595 # Revision 1.115  2002/04/02 01:56:10  richard
1596 #  . stop sending blank (whitespace-only) notes
1598 # Revision 1.114.2.4  2002/05/02 11:49:18  rochecompaan
1599 # Allow customization of the search filters that should be displayed
1600 # on the search page.
1602 # Revision 1.114.2.3  2002/04/20 13:23:31  rochecompaan
1603 # We now have a separate search page for nodes.  Search links for
1604 # different classes can be customized in instance_config similar to
1605 # index links.
1607 # Revision 1.114.2.2  2002/04/19 19:54:42  rochecompaan
1608 # cgi_client.py
1609 #     removed search link for the time being
1610 #     moved rendering of matches to htmltemplate
1611 # hyperdb.py
1612 #     filtering of nodes on full text search incorporated in filter method
1613 # roundupdb.py
1614 #     added paramater to call of filter method
1615 # roundup_indexer.py
1616 #     added search method to RoundupIndexer class
1618 # Revision 1.114.2.1  2002/04/03 11:55:57  rochecompaan
1619 #  . Added feature #526730 - search for messages capability
1621 # Revision 1.114  2002/03/17 23:06:05  richard
1622 # oops
1624 # Revision 1.113  2002/03/14 23:59:24  richard
1625 #  . #517734 ] web header customisation is obscure
1627 # Revision 1.112  2002/03/12 22:52:26  richard
1628 # more pychecker warnings removed
1630 # Revision 1.111  2002/02/25 04:32:21  richard
1631 # ahem
1633 # Revision 1.110  2002/02/21 07:19:08  richard
1634 # ... and label, width and height control for extra flavour!
1636 # Revision 1.109  2002/02/21 07:08:19  richard
1637 # oops
1639 # Revision 1.108  2002/02/21 07:02:54  richard
1640 # The correct var is "HTTP_HOST"
1642 # Revision 1.107  2002/02/21 06:57:38  richard
1643 #  . Added popup help for classes using the classhelp html template function.
1644 #    - add <display call="classhelp('priority', 'id,name,description')">
1645 #      to an item page, and it generates a link to a popup window which displays
1646 #      the id, name and description for the priority class. The description
1647 #      field won't exist in most installations, but it will be added to the
1648 #      default templates.
1650 # Revision 1.106  2002/02/21 06:23:00  richard
1651 # *** empty log message ***
1653 # Revision 1.105  2002/02/20 05:52:10  richard
1654 # better error handling
1656 # Revision 1.104  2002/02/20 05:45:17  richard
1657 # Use the csv module for generating the form entry so it's correct.
1658 # [also noted the sf.net feature request id in the change log]
1660 # Revision 1.103  2002/02/20 05:05:28  richard
1661 #  . Added simple editing for classes that don't define a templated interface.
1662 #    - access using the admin "class list" interface
1663 #    - limited to admin-only
1664 #    - requires the csv module from object-craft (url given if it's missing)
1666 # Revision 1.102  2002/02/15 07:08:44  richard
1667 #  . Alternate email addresses are now available for users. See the MIGRATION
1668 #    file for info on how to activate the feature.
1670 # Revision 1.101  2002/02/14 23:39:18  richard
1671 # . All forms now have "double-submit" protection when Javascript is enabled
1672 #   on the client-side.
1674 # Revision 1.100  2002/01/16 07:02:57  richard
1675 #  . lots of date/interval related changes:
1676 #    - more relaxed date format for input
1678 # Revision 1.99  2002/01/16 03:02:42  richard
1679 # #503793 ] changing assignedto resets nosy list
1681 # Revision 1.98  2002/01/14 02:20:14  richard
1682 #  . changed all config accesses so they access either the instance or the
1683 #    config attriubute on the db. This means that all config is obtained from
1684 #    instance_config instead of the mish-mash of classes. This will make
1685 #    switching to a ConfigParser setup easier too, I hope.
1687 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1688 # 0.5.0 switch, I hope!)
1690 # Revision 1.97  2002/01/11 23:22:29  richard
1691 #  . #502437 ] rogue reactor and unittest
1692 #    in short, the nosy reactor was modifying the nosy list. That code had
1693 #    been there for a long time, and I suspsect it was there because we
1694 #    weren't generating the nosy list correctly in other places of the code.
1695 #    We're now doing that, so the nosy-modifying code can go away from the
1696 #    nosy reactor.
1698 # Revision 1.96  2002/01/10 05:26:10  richard
1699 # missed a parsePropsFromForm in last update
1701 # Revision 1.95  2002/01/10 03:39:45  richard
1702 #  . fixed some problems with web editing and change detection
1704 # Revision 1.94  2002/01/09 13:54:21  grubert
1705 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1707 # Revision 1.93  2002/01/08 11:57:12  richard
1708 # crying out for real configuration handling... :(
1710 # Revision 1.92  2002/01/08 04:12:05  richard
1711 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1713 # Revision 1.91  2002/01/08 04:03:47  richard
1714 # I mucked the intent of the code up.
1716 # Revision 1.90  2002/01/08 03:56:55  richard
1717 # Oops, missed this before the beta:
1718 #  . #495392 ] empty nosy -patch
1720 # Revision 1.89  2002/01/07 20:24:45  richard
1721 # *mutter* stupid cutnpaste
1723 # Revision 1.88  2002/01/02 02:31:38  richard
1724 # Sorry for the huge checkin message - I was only intending to implement #496356
1725 # but I found a number of places where things had been broken by transactions:
1726 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1727 #    for _all_ roundup-generated smtp messages to be sent to.
1728 #  . the transaction cache had broken the roundupdb.Class set() reactors
1729 #  . newly-created author users in the mailgw weren't being committed to the db
1731 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1732 # on when I found that stuff :):
1733 #  . #496356 ] Use threading in messages
1734 #  . detectors were being registered multiple times
1735 #  . added tests for mailgw
1736 #  . much better attaching of erroneous messages in the mail gateway
1738 # Revision 1.87  2001/12/23 23:18:49  richard
1739 # We already had an admin-specific section of the web heading, no need to add
1740 # another one :)
1742 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1743 # Features added:
1744 #  .  Multilink properties are now displayed as comma separated values in
1745 #     a textbox
1746 #  .  The add user link is now only visible to the admin user
1747 #  .  Modified the mail gateway to reject submissions from unknown
1748 #     addresses if ANONYMOUS_ACCESS is denied
1750 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1751 # Bugs fixed:
1752 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1753 #     lost somewhere
1754 #   . Internet Explorer submits full path for filename - we now strip away
1755 #     the path
1756 # Features added:
1757 #   . Link and multilink properties are now displayed sorted in the cgi
1758 #     interface
1760 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1761 # Fixed bugs:
1762 #  .  Fixed file creation and retrieval in same transaction in anydbm
1763 #     backend
1764 #  .  Cgi interface now renders new issue after issue creation
1765 #  .  Could not set issue status to resolved through cgi interface
1766 #  .  Mail gateway was changing status back to 'chatting' if status was
1767 #     omitted as an argument
1769 # Revision 1.83  2001/12/15 23:51:01  richard
1770 # Tested the changes and fixed a few problems:
1771 #  . files are now attached to the issue as well as the message
1772 #  . newuser is a real method now since we don't want to do the message/file
1773 #    stuff for it
1774 #  . added some documentation
1775 # The really big changes in the diff are a result of me moving some code
1776 # around to keep like methods together a bit better.
1778 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1779 #  . Modified cgi interface to change properties only once all changes are
1780 #    collected, files created and messages generated.
1781 #  . Moved generation of change note to nosyreactors.
1782 #  . We now check for changes to "assignedto" to ensure it's added to the
1783 #    nosy list.
1785 # Revision 1.81  2001/12/12 23:55:00  richard
1786 # Fixed some problems with user editing
1788 # Revision 1.80  2001/12/12 23:27:14  richard
1789 # Added a Zope frontend for roundup.
1791 # Revision 1.79  2001/12/10 22:20:01  richard
1792 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1793 # where possible, only replacing methods where the db is opened (it uses the
1794 # btree opener specifically.)
1795 # Also cleaned up some change note generation.
1796 # Made the backends package work with pydoc too.
1798 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1799 # Fixed small bug that prevented adding issues through the web.
1801 # Revision 1.77  2001/12/06 22:48:29  richard
1802 # files multilink was being nuked in post_edit_node
1804 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1805 # Removed generation of change note from "sendmessage" in roundupdb.py.
1806 # The change note is now generated when the message is created.
1808 # Revision 1.75  2001/12/04 01:25:08  richard
1809 # Added some rollbacks where we were catching exceptions that would otherwise
1810 # have stopped committing.
1812 # Revision 1.74  2001/12/02 05:06:16  richard
1813 # . We now use weakrefs in the Classes to keep the database reference, so
1814 #   the close() method on the database is no longer needed.
1815 #   I bumped the minimum python requirement up to 2.1 accordingly.
1816 # . #487480 ] roundup-server
1817 # . #487476 ] INSTALL.txt
1819 # I also cleaned up the change message / post-edit stuff in the cgi client.
1820 # There's now a clearly marked "TODO: append the change note" where I believe
1821 # the change note should be added there. The "changes" list will obviously
1822 # have to be modified to be a dict of the changes, or somesuch.
1824 # More testing needed.
1826 # Revision 1.73  2001/12/01 07:17:50  richard
1827 # . We now have basic transaction support! Information is only written to
1828 #   the database when the commit() method is called. Only the anydbm
1829 #   backend is modified in this way - neither of the bsddb backends have been.
1830 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1831 #   doesn't have a commit command, so interactive users can't commit...)
1832 # . Fixed login/registration forwarding the user to the right page (or not,
1833 #   on a failure)
1835 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1836 # Links in page header are now consistent with default sort order.
1838 # Fixed bugs:
1839 #     - When login failed the list of issues were still rendered.
1840 #     - User was redirected to index page and not to his destination url
1841 #       if his first login attempt failed.
1843 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1844 # Property changes are now completely traceable, whether changes are
1845 # made through the web or by email
1847 # Revision 1.70  2001/11/30 00:06:29  richard
1848 # Converted roundup/cgi_client.py to use _()
1849 # Added the status file, I18N_PROGRESS.txt
1851 # Revision 1.69  2001/11/29 23:19:51  richard
1852 # Removed the "This issue has been edited through the web" when a valid
1853 # change note is supplied.
1855 # Revision 1.68  2001/11/29 04:57:23  richard
1856 # a little comment
1858 # Revision 1.67  2001/11/28 21:55:35  richard
1859 #  . login_action and newuser_action return values were being ignored
1860 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1861 #    gateway.
1862 #  (also a minor cleanup in hyperdb)
1864 # Revision 1.66  2001/11/27 03:00:50  richard
1865 # couple of bugfixes from latest patch integration
1867 # Revision 1.65  2001/11/26 23:00:53  richard
1868 # This config stuff is getting to be a real mess...
1870 # Revision 1.64  2001/11/26 22:56:35  richard
1871 # typo
1873 # Revision 1.63  2001/11/26 22:55:56  richard
1874 # Feature:
1875 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1876 #    the instance.
1877 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1878 #    signature info in e-mails.
1879 #  . Some more flexibility in the mail gateway and more error handling.
1880 #  . Login now takes you to the page you back to the were denied access to.
1882 # Fixed:
1883 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1885 # Revision 1.62  2001/11/24 00:45:42  jhermann
1886 # typeof() instead of type(): avoid clash with database field(?) "type"
1888 # Fixes this traceback:
1890 # Traceback (most recent call last):
1891 #   File "roundup\cgi_client.py", line 535, in newnode
1892 #     self._post_editnode(nid)
1893 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1894 #     if type(value) != type([]): value = [value]
1895 # UnboundLocalError: local variable 'type' referenced before assignment
1897 # Revision 1.61  2001/11/22 15:46:42  jhermann
1898 # Added module docstrings to all modules.
1900 # Revision 1.60  2001/11/21 22:57:28  jhermann
1901 # Added dummy hooks for I18N and some preliminary (test) markup of
1902 # translatable messages
1904 # Revision 1.59  2001/11/21 03:21:13  richard
1905 # oops
1907 # Revision 1.58  2001/11/21 03:11:28  richard
1908 # Better handling of new properties.
1910 # Revision 1.57  2001/11/15 10:24:27  richard
1911 # handle the case where there is no file attached
1913 # Revision 1.56  2001/11/14 21:35:21  richard
1914 #  . users may attach files to issues (and support in ext) through the web now
1916 # Revision 1.55  2001/11/07 02:34:06  jhermann
1917 # Handling of damaged login cookies
1919 # Revision 1.54  2001/11/07 01:16:12  richard
1920 # Remove the '=' padding from cookie value so quoting isn't an issue.
1922 # Revision 1.53  2001/11/06 23:22:05  jhermann
1923 # More IE fixes: it does not like quotes around cookie values; in the
1924 # hope this does not break anything for other browser; if it does, we
1925 # need to check HTTP_USER_AGENT
1927 # Revision 1.52  2001/11/06 23:11:22  jhermann
1928 # Fixed debug output in page footer; added expiry date to the login cookie
1929 # (expires 1 year in the future) to prevent probs with certain versions
1930 # of IE
1932 # Revision 1.51  2001/11/06 22:00:34  jhermann
1933 # Get debug level from ROUNDUP_DEBUG env var
1935 # Revision 1.50  2001/11/05 23:45:40  richard
1936 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1937 # Also made it present nicer error messages (not tracebacks).
1939 # Revision 1.49  2001/11/04 03:07:12  richard
1940 # Fixed various cookie-related bugs:
1941 #  . bug #477685 ] base64.decodestring breaks
1942 #  . bug #477837 ] lynx does not like the cookie
1943 #  . bug #477892 ] Password edit doesn't fix login cookie
1944 # Also closed a security hole - a logged-in user could edit another user's
1945 # details.
1947 # Revision 1.48  2001/11/03 01:30:18  richard
1948 # Oops. uses pagefoot now.
1950 # Revision 1.47  2001/11/03 01:29:28  richard
1951 # Login page didn't have all close tags.
1953 # Revision 1.46  2001/11/03 01:26:55  richard
1954 # possibly fix truncated base64'ed user:pass
1956 # Revision 1.45  2001/11/01 22:04:37  richard
1957 # Started work on supporting a pop3-fetching server
1958 # Fixed bugs:
1959 #  . bug #477104 ] HTML tag error in roundup-server
1960 #  . bug #477107 ] HTTP header problem
1962 # Revision 1.44  2001/10/28 23:03:08  richard
1963 # Added more useful header to the classic schema.
1965 # Revision 1.43  2001/10/24 00:01:42  richard
1966 # More fixes to lockout logic.
1968 # Revision 1.42  2001/10/23 23:56:03  richard
1969 # HTML typo
1971 # Revision 1.41  2001/10/23 23:52:35  richard
1972 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1974 # Revision 1.40  2001/10/23 23:06:39  richard
1975 # Some cleanup.
1977 # Revision 1.39  2001/10/23 01:00:18  richard
1978 # Re-enabled login and registration access after lopping them off via
1979 # disabling access for anonymous users.
1980 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1981 # a couple of bugs while I was there. Probably introduced a couple, but
1982 # things seem to work OK at the moment.
1984 # Revision 1.38  2001/10/22 03:25:01  richard
1985 # Added configuration for:
1986 #  . anonymous user access and registration (deny/allow)
1987 #  . filter "widget" location on index page (top, bottom, both)
1988 # Updated some documentation.
1990 # Revision 1.37  2001/10/21 07:26:35  richard
1991 # feature #473127: Filenames. I modified the file.index and htmltemplate
1992 #  source so that the filename is used in the link and the creation
1993 #  information is displayed.
1995 # Revision 1.36  2001/10/21 04:44:50  richard
1996 # bug #473124: UI inconsistency with Link fields.
1997 #    This also prompted me to fix a fairly long-standing usability issue -
1998 #    that of being able to turn off certain filters.
2000 # Revision 1.35  2001/10/21 00:17:54  richard
2001 # CGI interface view customisation section may now be hidden (patch from
2002 #  Roch'e Compaan.)
2004 # Revision 1.34  2001/10/20 11:58:48  richard
2005 # Catch errors in login - no username or password supplied.
2006 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
2008 # Revision 1.33  2001/10/17 00:18:41  richard
2009 # Manually constructing cookie headers now.
2011 # Revision 1.32  2001/10/16 03:36:21  richard
2012 # CGI interface wasn't handling checkboxes at all.
2014 # Revision 1.31  2001/10/14 10:55:00  richard
2015 # Handle empty strings in HTML template Link function
2017 # Revision 1.30  2001/10/09 07:38:58  richard
2018 # Pushed the base code for the extended schema CGI interface back into the
2019 # code cgi_client module so that future updates will be less painful.
2020 # Also removed a debugging print statement from cgi_client.
2022 # Revision 1.29  2001/10/09 07:25:59  richard
2023 # Added the Password property type. See "pydoc roundup.password" for
2024 # implementation details. Have updated some of the documentation too.
2026 # Revision 1.28  2001/10/08 00:34:31  richard
2027 # Change message was stuffing up for multilinks with no key property.
2029 # Revision 1.27  2001/10/05 02:23:24  richard
2030 #  . roundup-admin create now prompts for property info if none is supplied
2031 #    on the command-line.
2032 #  . hyperdb Class getprops() method may now return only the mutable
2033 #    properties.
2034 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
2035 #    now support anonymous user access (read-only, unless there's an
2036 #    "anonymous" user, in which case write access is permitted). Login
2037 #    handling has been moved into cgi_client.Client.main()
2038 #  . The "extended" schema is now the default in roundup init.
2039 #  . The schemas have had their page headings modified to cope with the new
2040 #    login handling. Existing installations should copy the interfaces.py
2041 #    file from the roundup lib directory to their instance home.
2042 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
2043 #    Ping - has been removed.
2044 #  . Fixed a whole bunch of places in the CGI interface where we should have
2045 #    been returning Not Found instead of throwing an exception.
2046 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
2047 #    an item now throws an exception.
2049 # Revision 1.26  2001/09/12 08:31:42  richard
2050 # handle cases where mime type is not guessable
2052 # Revision 1.25  2001/08/29 05:30:49  richard
2053 # change messages weren't being saved when there was no-one on the nosy list.
2055 # Revision 1.24  2001/08/29 04:49:39  richard
2056 # didn't clean up fully after debugging :(
2058 # Revision 1.23  2001/08/29 04:47:18  richard
2059 # Fixed CGI client change messages so they actually include the properties
2060 # changed (again).
2062 # Revision 1.22  2001/08/17 00:08:10  richard
2063 # reverted back to sending messages always regardless of who is doing the web
2064 # edit. change notes weren't being saved. bleah. hackish.
2066 # Revision 1.21  2001/08/15 23:43:18  richard
2067 # Fixed some isFooTypes that I missed.
2068 # Refactored some code in the CGI code.
2070 # Revision 1.20  2001/08/12 06:32:36  richard
2071 # using isinstance(blah, Foo) now instead of isFooType
2073 # Revision 1.19  2001/08/07 00:24:42  richard
2074 # stupid typo
2076 # Revision 1.18  2001/08/07 00:15:51  richard
2077 # Added the copyright/license notice to (nearly) all files at request of
2078 # Bizar Software.
2080 # Revision 1.17  2001/08/02 06:38:17  richard
2081 # Roundupdb now appends "mailing list" information to its messages which
2082 # include the e-mail address and web interface address. Templates may
2083 # override this in their db classes to include specific information (support
2084 # instructions, etc).
2086 # Revision 1.16  2001/08/02 05:55:25  richard
2087 # Web edit messages aren't sent to the person who did the edit any more. No
2088 # message is generated if they are the only person on the nosy list.
2090 # Revision 1.15  2001/08/02 00:34:10  richard
2091 # bleah syntax error
2093 # Revision 1.14  2001/08/02 00:26:16  richard
2094 # Changed the order of the information in the message generated by web edits.
2096 # Revision 1.13  2001/07/30 08:12:17  richard
2097 # Added time logging and file uploading to the templates.
2099 # Revision 1.12  2001/07/30 06:26:31  richard
2100 # Added some documentation on how the newblah works.
2102 # Revision 1.11  2001/07/30 06:17:45  richard
2103 # Features:
2104 #  . Added ability for cgi newblah forms to indicate that the new node
2105 #    should be linked somewhere.
2106 # Fixed:
2107 #  . Fixed the agument handling for the roundup-admin find command.
2108 #  . Fixed handling of summary when no note supplied for newblah. Again.
2109 #  . Fixed detection of no form in htmltemplate Field display.
2111 # Revision 1.10  2001/07/30 02:37:34  richard
2112 # Temporary measure until we have decent schema migration...
2114 # Revision 1.9  2001/07/30 01:25:07  richard
2115 # Default implementation is now "classic" rather than "extended" as one would
2116 # expect.
2118 # Revision 1.8  2001/07/29 08:27:40  richard
2119 # Fixed handling of passed-in values in form elements (ie. during a
2120 # drill-down)
2122 # Revision 1.7  2001/07/29 07:01:39  richard
2123 # Added vim command to all source so that we don't get no steenkin' tabs :)
2125 # Revision 1.6  2001/07/29 04:04:00  richard
2126 # Moved some code around allowing for subclassing to change behaviour.
2128 # Revision 1.5  2001/07/28 08:16:52  richard
2129 # New issue form handles lack of note better now.
2131 # Revision 1.4  2001/07/28 00:34:34  richard
2132 # Fixed some non-string node ids.
2134 # Revision 1.3  2001/07/23 03:56:30  richard
2135 # oops, missed a config removal
2137 # Revision 1.2  2001/07/22 12:09:32  richard
2138 # Final commit of Grande Splite
2140 # Revision 1.1  2001/07/22 11:58:35  richard
2141 # More Grande Splite
2144 # vim: set filetype=python ts=4 sw=4 et si