Code

removed debugging
[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.137 2002-07-10 07:00:30 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         defaults = 1
395         for key in ':sort', ':group', ':filter', ':columns', ':pagesize':
396             if self.form.has_key(key):
397                 defaults = 0
398                 break
399         if defaults:
400             # try the instance config first
401             if hasattr(self.instance, 'DEFAULT_INDEX'):
402                 d = self.instance.DEFAULT_INDEX
403                 self.classname = d['CLASS']
404                 sort = d['SORT']
405                 group = d['GROUP']
406                 filter = d['FILTER']
407                 columns = d['COLUMNS']
408                 filterspec = d['FILTERSPEC']
409                 pagesize = d.get('PAGESIZE', '50')
411             else:
412                 # nope - fall back on the old way of doing it
413                 self.classname = 'issue'
414                 sort = self.default_index_sort
415                 group = self.default_index_group
416                 filter = self.default_index_filter
417                 columns = self.default_index_columns
418                 filterspec = self.default_index_filterspec
419                 pagesize = self.default_pagesize
420         else:
421             # make list() extract the info from the CGI environ
422             self.classname = 'issue'
423             sort = group = filter = columns = filterspec = pagesize = None
424         return columns, filter, group, sort, filterspec, pagesize
426     def index(self):
427         ''' put up an index - no class specified
428         '''
429         columns, filter, group, sort, filterspec, pagesize = \
430             self._get_customisation_info()
431         return self.list(columns=columns, filter=filter, group=group,
432             sort=sort, filterspec=filterspec, pagesize=pagesize)
434     def searchnode(self):
435         columns, filter, group, sort, filterspec, pagesize = \
436             self._get_customisation_info()
437         cn = self.classname
438         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
439             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
440         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
441         self.write('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
442         all_columns = self.db.getclass(cn).getprops().keys()
443         all_columns.sort()
444         index.filter_section('', filter, columns, group, all_columns, sort,
445                              filterspec, pagesize, 0)
446         self.pagefoot()
447         index.db = index.cl = index.properties = None
448         index.clear()
450     # XXX deviates from spec - loses the '+' (that's a reserved character
451     # in URLS
452     def list(self, sort=None, group=None, filter=None, columns=None,
453             filterspec=None, show_customization=None, show_nodes=1, pagesize=None):
454         ''' call the template index with the args
456             :sort    - sort by prop name, optionally preceeded with '-'
457                      to give descending or nothing for ascending sorting.
458             :group   - group by prop name, optionally preceeded with '-' or
459                      to sort in descending or nothing for ascending order.
460             :filter  - selects which props should be displayed in the filter
461                      section. Default is all.
462             :columns - selects the columns that should be displayed.
463                      Default is all.
465         '''
466         cn = self.classname
467         cl = self.db.classes[cn]
468         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
469             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
470         if sort is None: sort = self.index_sort()
471         if group is None: group = self.index_arg(':group')
472         if filter is None: filter = self.index_arg(':filter')
473         if columns is None: columns = self.index_arg(':columns')
474         if filterspec is None: filterspec = self.index_filterspec(filter)
475         if show_customization is None:
476             show_customization = self.customization_widget()
477         if self.form.has_key('search_text'):
478             search_text = self.form['search_text'].value
479         else:
480             search_text = ''
481         if pagesize is None:
482             if self.form.has_key(':pagesize'):
483                 pagesize = self.form[':pagesize'].value
484             else:
485                 pagesize = '50'
486         pagesize = int(pagesize)
487         if self.form.has_key(':startwith'):
488             startwith = int(self.form[':startwith'].value)
489         else:
490             startwith = 0
492         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
493         try:
494             index.render(filterspec, search_text, filter, columns, sort, 
495                 group, show_customization=show_customization, 
496                 show_nodes=show_nodes, pagesize=pagesize, startwith=startwith)
497         except htmltemplate.MissingTemplateError:
498             self.basicClassEditPage()
499         self.pagefoot()
501     def basicClassEditPage(self):
502         '''Display a basic edit page that allows simple editing of the
503            nodes of the current class
504         '''
505         if self.user != 'admin':
506             raise Unauthorised
507         w = self.write
508         cn = self.classname
509         cl = self.db.classes[cn]
510         idlessprops = cl.getprops(protected=0).keys()
511         props = ['id'] + idlessprops
514         # get the CSV module
515         try:
516             import csv
517         except ImportError:
518             w(_('Sorry, you need the csv module to use this function.<br>\n'
519                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
520             return
522         # do the edit
523         if self.form.has_key('rows'):
524             rows = self.form['rows'].value.splitlines()
525             p = csv.parser()
526             found = {}
527             line = 0
528             for row in rows:
529                 line += 1
530                 values = p.parse(row)
531                 # not a complete row, keep going
532                 if not values: continue
534                 # extract the nodeid
535                 nodeid, values = values[0], values[1:]
536                 found[nodeid] = 1
538                 # confirm correct weight
539                 if len(idlessprops) != len(values):
540                     w(_('Not enough values on line %(line)s'%{'line':line}))
541                     return
543                 # extract the new values
544                 d = {}
545                 for name, value in zip(idlessprops, values):
546                     d[name] = value.strip()
548                 # perform the edit
549                 if cl.hasnode(nodeid):
550                     # edit existing
551                     cl.set(nodeid, **d)
552                 else:
553                     # new node
554                     found[cl.create(**d)] = 1
556             # retire the removed entries
557             for nodeid in cl.list():
558                 if not found.has_key(nodeid):
559                     cl.retire(nodeid)
561         w(_('''<p class="form-help">You may edit the contents of the
562         "%(classname)s" class using this form. The lines are full-featured
563         Comma-Separated-Value lines, so you may include commas and even
564         newlines by enclosing the values in double-quotes ("). Double
565         quotes themselves must be quoted by doubling ("").</p>
566         <p class="form-help">Remove entries by deleting their line. Add
567         new entries by appending
568         them to the table - put an X in the id column.</p>''')%{'classname':cn})
570         l = []
571         for name in props:
572             l.append(name)
573         w('<tt>')
574         w(', '.join(l) + '\n')
575         w('</tt>')
577         w('<form onSubmit="return submit_once()" method="POST">')
578         w('<textarea name="rows" cols=80 rows=15>')
579         p = csv.parser()
580         for nodeid in cl.list():
581             l = []
582             for name in props:
583                 l.append(cgi.escape(str(cl.get(nodeid, name))))
584             w(p.join(l) + '\n')
586         w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
588     def classhelp(self):
589         '''Display a table of class info
590         '''
591         w = self.write
592         cn = self.form['classname'].value
593         cl = self.db.classes[cn]
594         props = self.form['properties'].value.split(',')
595         if cl.labelprop(1) in props:
596             sort = [cl.labelprop(1)]
597         else:
598             sort = props[0]
600         w('<table border=1 cellspacing=0 cellpaddin=2>')
601         w('<tr>')
602         for name in props:
603             w('<th align=left>%s</th>'%name)
604         w('</tr>')
605         for nodeid in cl.filter(None, {}, sort, []): #cl.list():
606             w('<tr>')
607             for name in props:
608                 value = cgi.escape(str(cl.get(nodeid, name)))
609                 w('<td align="left" valign="top">%s</td>'%value)
610             w('</tr>')
611         w('</table>')
613     def shownode(self, message=None, num_re=re.compile('^\d+$')):
614         ''' display an item
615         '''
616         cn = self.classname
617         cl = self.db.classes[cn]
618         if self.form.has_key(':multilink'):
619             link = self.form[':multilink'].value
620             designator, linkprop = link.split(':')
621             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
622         else:
623             xtra = ''
625         # possibly perform an edit
626         keys = self.form.keys()
627         # don't try to set properties if the user has just logged in
628         if keys and not self.form.has_key('__login_name'):
629             try:
630                 props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
631                 # make changes to the node
632                 self._changenode(props)
633                 # handle linked nodes 
634                 self._post_editnode(self.nodeid)
635                 # and some nice feedback for the user
636                 if props:
637                     message = _('%(changes)s edited ok')%{'changes':
638                         ', '.join(props.keys())}
639                 elif self.form.has_key('__note') and self.form['__note'].value:
640                     message = _('note added')
641                 elif (self.form.has_key('__file') and
642                         self.form['__file'].filename):
643                     message = _('file added')
644                 else:
645                     message = _('nothing changed')
646             except:
647                 self.db.rollback()
648                 s = StringIO.StringIO()
649                 traceback.print_exc(None, s)
650                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
652         # now the display
653         id = self.nodeid
654         if cl.getkey():
655             id = cl.get(id, cl.getkey())
656         self.pagehead('%s: %s %s'%(self.classname.capitalize(), id, xtra),
657             message)
659         nodeid = self.nodeid
661         # use the template to display the item
662         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
663             self.classname)
664         item.render(nodeid)
666         self.pagefoot()
667     showissue = shownode
668     showmsg = shownode
669     searchissue = searchnode
671     def _changenode(self, props):
672         ''' change the node based on the contents of the form
673         '''
674         cl = self.db.classes[self.classname]
676         # create the message
677         message, files = self._handle_message()
678         if message:
679             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
680         if files:
681             props['files'] = cl.get(self.nodeid, 'files') + files
683         # make the changes
684         cl.set(self.nodeid, **props)
686     def _createnode(self):
687         ''' create a node based on the contents of the form
688         '''
689         cl = self.db.classes[self.classname]
690         props = parsePropsFromForm(self.db, cl, self.form)
692         # check for messages and files
693         message, files = self._handle_message()
694         if message:
695             props['messages'] = [message]
696         if files:
697             props['files'] = files
698         # create the node and return it's id
699         return cl.create(**props)
701     def _handle_message(self):
702         ''' generate an edit message
703         '''
704         # handle file attachments 
705         files = []
706         if self.form.has_key('__file'):
707             file = self.form['__file']
708             if file.filename:
709                 filename = file.filename.split('\\')[-1]
710                 mime_type = mimetypes.guess_type(filename)[0]
711                 if not mime_type:
712                     mime_type = "application/octet-stream"
713                 # create the new file entry
714                 files.append(self.db.file.create(type=mime_type,
715                     name=filename, content=file.file.read()))
717         # we don't want to do a message if none of the following is true...
718         cn = self.classname
719         cl = self.db.classes[self.classname]
720         props = cl.getprops()
721         note = None
722         # in a nutshell, don't do anything if there's no note or there's no
723         # NOSY
724         if self.form.has_key('__note'):
725             note = self.form['__note'].value.strip()
726         if not note:
727             return None, files
728         if not props.has_key('messages'):
729             return None, files
730         if not isinstance(props['messages'], hyperdb.Multilink):
731             return None, files
732         if not props['messages'].classname == 'msg':
733             return None, files
734         if not (self.form.has_key('nosy') or note):
735             return None, files
737         # handle the note
738         if '\n' in note:
739             summary = re.split(r'\n\r?', note)[0]
740         else:
741             summary = note
742         m = ['%s\n'%note]
744         # handle the messageid
745         # TODO: handle inreplyto
746         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
747             self.classname, self.instance.MAIL_DOMAIN)
749         # now create the message, attaching the files
750         content = '\n'.join(m)
751         message_id = self.db.msg.create(author=self.getuid(),
752             recipients=[], date=date.Date('.'), summary=summary,
753             content=content, files=files, messageid=messageid)
755         # update the messages property
756         return message_id, files
758     def _post_editnode(self, nid):
759         '''Do the linking part of the node creation.
761            If a form element has :link or :multilink appended to it, its
762            value specifies a node designator and the property on that node
763            to add _this_ node to as a link or multilink.
765            This is typically used on, eg. the file upload page to indicated
766            which issue to link the file to.
768            TODO: I suspect that this and newfile will go away now that
769            there's the ability to upload a file using the issue __file form
770            element!
771         '''
772         cn = self.classname
773         cl = self.db.classes[cn]
774         # link if necessary
775         keys = self.form.keys()
776         for key in keys:
777             if key == ':multilink':
778                 value = self.form[key].value
779                 if type(value) != type([]): value = [value]
780                 for value in value:
781                     designator, property = value.split(':')
782                     link, nodeid = roundupdb.splitDesignator(designator)
783                     link = self.db.classes[link]
784                     # take a dupe of the list so we're not changing the cache
785                     value = link.get(nodeid, property)[:]
786                     value.append(nid)
787                     link.set(nodeid, **{property: value})
788             elif key == ':link':
789                 value = self.form[key].value
790                 if type(value) != type([]): value = [value]
791                 for value in value:
792                     designator, property = value.split(':')
793                     link, nodeid = roundupdb.splitDesignator(designator)
794                     link = self.db.classes[link]
795                     link.set(nodeid, **{property: nid})
797     def newnode(self, message=None):
798         ''' Add a new node to the database.
799         
800         The form works in two modes: blank form and submission (that is,
801         the submission goes to the same URL). **Eventually this means that
802         the form will have previously entered information in it if
803         submission fails.
805         The new node will be created with the properties specified in the
806         form submission. For multilinks, multiple form entries are handled,
807         as are prop=value,value,value. You can't mix them though.
809         If the new node is to be referenced from somewhere else immediately
810         (ie. the new node is a file that is to be attached to a support
811         issue) then supply one of these arguments in addition to the usual
812         form entries:
813             :link=designator:property
814             :multilink=designator:property
815         ... which means that once the new node is created, the "property"
816         on the node given by "designator" should now reference the new
817         node's id. The node id will be appended to the multilink.
818         '''
819         cn = self.classname
820         cl = self.db.classes[cn]
821         if self.form.has_key(':multilink'):
822             link = self.form[':multilink'].value
823             designator, linkprop = link.split(':')
824             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
825         else:
826             xtra = ''
828         # possibly perform a create
829         keys = self.form.keys()
830         if [i for i in keys if i[0] != ':']:
831             props = {}
832             try:
833                 nid = self._createnode()
834                 # handle linked nodes 
835                 self._post_editnode(nid)
836                 # and some nice feedback for the user
837                 message = _('%(classname)s created ok')%{'classname': cn}
839                 # render the newly created issue
840                 self.db.commit()
841                 self.nodeid = nid
842                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
843                     message)
844                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
845                     self.classname)
846                 item.render(nid)
847                 self.pagefoot()
848                 return
849             except:
850                 self.db.rollback()
851                 s = StringIO.StringIO()
852                 traceback.print_exc(None, s)
853                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
854         self.pagehead(_('New %(classname)s %(xtra)s')%{
855                 'classname': self.classname.capitalize(),
856                 'xtra': xtra }, message)
858         # call the template
859         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
860             self.classname)
861         newitem.render(self.form)
863         self.pagefoot()
864     newissue = newnode
866     def newuser(self, message=None):
867         ''' Add a new user to the database.
869             Don't do any of the message or file handling, just create the node.
870         '''
871         cn = self.classname
872         cl = self.db.classes[cn]
874         # possibly perform a create
875         keys = self.form.keys()
876         if [i for i in keys if i[0] != ':']:
877             try:
878                 props = parsePropsFromForm(self.db, cl, self.form)
879                 nid = cl.create(**props)
880                 # handle linked nodes 
881                 self._post_editnode(nid)
882                 # and some nice feedback for the user
883                 message = _('%(classname)s created ok')%{'classname': cn}
884             except:
885                 self.db.rollback()
886                 s = StringIO.StringIO()
887                 traceback.print_exc(None, s)
888                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
889         self.pagehead(_('New %(classname)s')%{'classname':
890              self.classname.capitalize()}, message)
892         # call the template
893         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
894             self.classname)
895         newitem.render(self.form)
897         self.pagefoot()
899     def newfile(self, message=None):
900         ''' Add a new file to the database.
901         
902         This form works very much the same way as newnode - it just has a
903         file upload.
904         '''
905         cn = self.classname
906         cl = self.db.classes[cn]
907         props = parsePropsFromForm(self.db, cl, self.form)
908         if self.form.has_key(':multilink'):
909             link = self.form[':multilink'].value
910             designator, linkprop = link.split(':')
911             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
912         else:
913             xtra = ''
915         # possibly perform a create
916         keys = self.form.keys()
917         if [i for i in keys if i[0] != ':']:
918             try:
919                 file = self.form['content']
920                 mime_type = mimetypes.guess_type(file.filename)[0]
921                 if not mime_type:
922                     mime_type = "application/octet-stream"
923                 # save the file
924                 props['type'] = mime_type
925                 props['name'] = file.filename
926                 props['content'] = file.file.read()
927                 nid = cl.create(**props)
928                 # handle linked nodes
929                 self._post_editnode(nid)
930                 # and some nice feedback for the user
931                 message = _('%(classname)s created ok')%{'classname': cn}
932             except:
933                 self.db.rollback()
934                 s = StringIO.StringIO()
935                 traceback.print_exc(None, s)
936                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
938         self.pagehead(_('New %(classname)s %(xtra)s')%{
939                 'classname': self.classname.capitalize(),
940                 'xtra': xtra }, message)
941         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
942             self.classname)
943         newitem.render(self.form)
944         self.pagefoot()
946     def showuser(self, message=None, num_re=re.compile('^\d+$')):
947         '''Display a user page for editing. Make sure the user is allowed
948             to edit this node, and also check for password changes.
949         '''
950         if self.user == 'anonymous':
951             raise Unauthorised
953         user = self.db.user
955         # get the username of the node being edited
956         node_user = user.get(self.nodeid, 'username')
958         if self.user not in ('admin', node_user):
959             raise Unauthorised
961         #
962         # perform any editing
963         #
964         keys = self.form.keys()
965         if keys:
966             try:
967                 props = parsePropsFromForm(self.db, user, self.form,
968                     self.nodeid)
969                 set_cookie = 0
970                 if props.has_key('password'):
971                     password = self.form['password'].value.strip()
972                     if not password:
973                         # no password was supplied - don't change it
974                         del props['password']
975                     elif self.nodeid == self.getuid():
976                         # this is the logged-in user's password
977                         set_cookie = password
978                 user.set(self.nodeid, **props)
979                 # and some feedback for the user
980                 message = _('%(changes)s edited ok')%{'changes':
981                     ', '.join(props.keys())}
982             except:
983                 self.db.rollback()
984                 s = StringIO.StringIO()
985                 traceback.print_exc(None, s)
986                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
987         else:
988             set_cookie = 0
990         # fix the cookie if the password has changed
991         if set_cookie:
992             self.set_cookie(self.user, set_cookie)
994         #
995         # now the display
996         #
997         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
999         # use the template to display the item
1000         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
1001         item.render(self.nodeid)
1002         self.pagefoot()
1004     def showfile(self):
1005         ''' display a file
1006         '''
1007         nodeid = self.nodeid
1008         cl = self.db.classes[self.classname]
1009         mime_type = cl.get(nodeid, 'type')
1010         if mime_type == 'message/rfc822':
1011             mime_type = 'text/plain'
1012         self.header(headers={'Content-Type': mime_type})
1013         self.write(cl.get(nodeid, 'content'))
1015     def classes(self, message=None):
1016         ''' display a list of all the classes in the database
1017         '''
1018         if self.user == 'admin':
1019             self.pagehead(_('Table of classes'), message)
1020             classnames = self.db.classes.keys()
1021             classnames.sort()
1022             self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
1023             for cn in classnames:
1024                 cl = self.db.getclass(cn)
1025                 self.write('<tr class="list-header"><th colspan=2 align=left>'
1026                     '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
1027                 for key, value in cl.properties.items():
1028                     if value is None: value = ''
1029                     else: value = str(value)
1030                     self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
1031                         key, cgi.escape(value)))
1032             self.write('</table>')
1033             self.pagefoot()
1034         else:
1035             raise Unauthorised
1037     def login(self, message=None, newuser_form=None, action='index'):
1038         '''Display a login page.
1039         '''
1040         self.pagehead(_('Login to roundup'), message)
1041         self.write(_('''
1042 <table>
1043 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
1044 <form onSubmit="return submit_once()" action="login_action" method=POST>
1045 <input type="hidden" name="__destination_url" value="%(action)s">
1046 <tr><td align=right>Login name: </td>
1047     <td><input name="__login_name"></td></tr>
1048 <tr><td align=right>Password: </td>
1049     <td><input type="password" name="__login_password"></td></tr>
1050 <tr><td></td>
1051     <td><input type="submit" value="Log In"></td></tr>
1052 </form>
1053 ''')%locals())
1054         if self.user is None and self.instance.ANONYMOUS_REGISTER == 'deny':
1055             self.write('</table>')
1056             self.pagefoot()
1057             return
1058         values = {'realname': '', 'organisation': '', 'address': '',
1059             'phone': '', 'username': '', 'password': '', 'confirm': '',
1060             'action': action, 'alternate_addresses': ''}
1061         if newuser_form is not None:
1062             for key in newuser_form.keys():
1063                 values[key] = newuser_form[key].value
1064         self.write(_('''
1065 <p>
1066 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1067 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1068 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1069 <input type="hidden" name="__destination_url" value="%(action)s">
1070 <tr><td align=right><em>Name: </em></td>
1071     <td><input name="realname" value="%(realname)s" size=40></td></tr>
1072 <tr><td align=right><em>Organisation: </em></td>
1073     <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1074 <tr><td align=right>E-Mail Address: </td>
1075     <td><input name="address" value="%(address)s" size=40></td></tr>
1076 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1077     <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1078 <tr><td align=right><em>Phone: </em></td>
1079     <td><input name="phone" value="%(phone)s"></td></tr>
1080 <tr><td align=right>Preferred Login name: </td>
1081     <td><input name="username" value="%(username)s"></td></tr>
1082 <tr><td align=right>Password: </td>
1083     <td><input type="password" name="password" value="%(password)s"></td></tr>
1084 <tr><td align=right>Password Again: </td>
1085     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1086 <tr><td></td>
1087     <td><input type="submit" value="Register"></td></tr>
1088 </form>
1089 </table>
1090 ''')%values)
1091         self.pagefoot()
1093     def login_action(self, message=None):
1094         '''Attempt to log a user in and set the cookie
1096         returns 0 if a page is generated as a result of this call, and
1097         1 if not (ie. the login is successful
1098         '''
1099         if not self.form.has_key('__login_name'):
1100             self.login(message=_('Username required'))
1101             return 0
1102         self.user = self.form['__login_name'].value
1103         # re-open the database for real, using the user
1104         self.opendb(self.user)
1105         if self.form.has_key('__login_password'):
1106             password = self.form['__login_password'].value
1107         else:
1108             password = ''
1109         # make sure the user exists
1110         try:
1111             uid = self.db.user.lookup(self.user)
1112         except KeyError:
1113             name = self.user
1114             self.make_user_anonymous()
1115             action = self.form['__destination_url'].value
1116             self.login(message=_('No such user "%(name)s"')%locals(),
1117                 action=action)
1118             return 0
1120         # and that the password is correct
1121         pw = self.db.user.get(uid, 'password')
1122         if password != pw:
1123             self.make_user_anonymous()
1124             action = self.form['__destination_url'].value
1125             self.login(message=_('Incorrect password'), action=action)
1126             return 0
1128         self.set_cookie(self.user, password)
1129         return 1
1131     def newuser_action(self, message=None):
1132         '''Attempt to create a new user based on the contents of the form
1133         and then set the cookie.
1135         return 1 on successful login
1136         '''
1137         # re-open the database as "admin"
1138         self.opendb('admin')
1140         # TODO: pre-check the required fields and username key property
1141         cl = self.db.user
1142         try:
1143             props = parsePropsFromForm(self.db, cl, self.form)
1144             uid = cl.create(**props)
1145         except ValueError, message:
1146             action = self.form['__destination_url'].value
1147             self.login(message, action=action)
1148             return 0
1149         self.user = cl.get(uid, 'username')
1150         # re-open the database for real, using the user
1151         self.opendb(self.user)
1152         password = cl.get(uid, 'password')
1153         self.set_cookie(self.user, self.form['password'].value)
1154         return 1
1156     def set_cookie(self, user, password):
1157         # TODO generate a much, much stronger session key ;)
1158         session = binascii.b2a_base64(repr(time.time())).strip()
1160         # clean up the base64
1161         if session[-1] == '=':
1162           if session[-2] == '=':
1163             session = session[:-2]
1164           else:
1165             session = session[:-1]
1167         print 'session set to', `session`
1169         # insert the session in the sessiondb
1170         sessions = self.db.getclass('__sessions')
1171         self.session = sessions.create(sessid=session, user=user,
1172             last_use=date.Date())
1174         # and commit immediately
1175         self.db.commit()
1177         # expire us in a long, long time
1178         # TODO: hrm, how long should this be, and how many sessions can one
1179         # user have?
1180         expire = Cookie._getdate(86400*365)
1182         # generate the cookie path - make sure it has a trailing '/'
1183         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1184             ''))
1185         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
1186             session, expire, path)})
1188     def make_user_anonymous(self):
1189         # make us anonymous if we can
1190         try:
1191             self.db.user.lookup('anonymous')
1192             self.user = 'anonymous'
1193         except KeyError:
1194             self.user = None
1196     def logout(self, message=None):
1197         self.make_user_anonymous()
1198         # construct the logout cookie
1199         now = Cookie._getdate()
1200         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1201             ''))
1202         self.header({'Set-Cookie':
1203             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1204             path)})
1205         self.login()
1207     def opendb(self, user):
1208         ''' Open the database - but include the definition of the sessions db.
1209         '''
1210         # open the db
1211         self.db = self.instance.open(user)
1213         # make sure we have the session Class
1214         try:
1215             sessions = self.db.getclass('__sessions')
1216         except:
1217             # add the sessions Class
1218             sessions = hyperdb.Class(self.db, '__sessions',
1219                 sessid=hyperdb.String(), user=hyperdb.String(),
1220                 last_use=hyperdb.Date())
1221             sessions.setkey('sessid')
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 doesn't need to 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                 sessions.set(self.session, last_use=date.Date())
1257                 self.db.commit()
1258                 user = sessions.get(sessid, 'user')
1260         # make sure the anonymous user is valid if we're using it
1261         if user == 'anonymous':
1262             self.make_user_anonymous()
1263         else:
1264             self.user = user
1266         # now figure which function to call
1267         path = self.split_path
1269         # default action to index if the path has no information in it
1270         if not path or path[0] in ('', 'index'):
1271             action = 'index'
1272         else:
1273             action = path[0]
1275         # Everthing ignores path[1:]
1276         #  - The file download link generator actually relies on this - it
1277         #    appends the name of the file to the URL so the download file name
1278         #    is correct, but doesn't actually use it.
1280         # everyone is allowed to try to log in
1281         if action == 'login_action':
1282             # try to login
1283             if not self.login_action():
1284                 return
1285             # figure the resulting page
1286             action = self.form['__destination_url'].value
1287             if not action:
1288                 action = 'index'
1289             self.do_action(action)
1290             return
1292         # allow anonymous people to register
1293         if action == 'newuser_action':
1294             # if we don't have a login and anonymous people aren't allowed to
1295             # register, then spit up the login form
1296             if self.instance.ANONYMOUS_REGISTER == 'deny' and self.user is None:
1297                 if action == 'login':
1298                     self.login()         # go to the index after login
1299                 else:
1300                     self.login(action=action)
1301                 return
1302             # try to add the user
1303             if not self.newuser_action():
1304                 return
1305             # figure the resulting page
1306             action = self.form['__destination_url'].value
1307             if not action:
1308                 action = 'index'
1310         # no login or registration, make sure totally anonymous access is OK
1311         elif self.instance.ANONYMOUS_ACCESS == 'deny' and self.user is None:
1312             if action == 'login':
1313                 self.login()             # go to the index after login
1314             else:
1315                 self.login(action=action)
1316             return
1318         # re-open the database for real, using the user
1319         self.opendb(self.user)
1321         # just a regular action
1322         self.do_action(action)
1324         # commit all changes to the database
1325         self.db.commit()
1327     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1328             nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1329         '''Figure the user's action and do it.
1330         '''
1331         # here be the "normal" functionality
1332         if action == 'index':
1333             self.index()
1334             return
1335         if action == 'list_classes':
1336             self.classes()
1337             return
1338         if action == 'classhelp':
1339             self.classhelp()
1340             return
1341         if action == 'login':
1342             self.login()
1343             return
1344         if action == 'logout':
1345             self.logout()
1346             return
1348         # see if we're to display an existing node
1349         m = dre.match(action)
1350         if m:
1351             self.classname = m.group(1)
1352             self.nodeid = m.group(2)
1353             try:
1354                 cl = self.db.classes[self.classname]
1355             except KeyError:
1356                 raise NotFound, self.classname
1357             try:
1358                 cl.get(self.nodeid, 'id')
1359             except IndexError:
1360                 raise NotFound, self.nodeid
1361             try:
1362                 func = getattr(self, 'show%s'%self.classname)
1363             except AttributeError:
1364                 raise NotFound, 'show%s'%self.classname
1365             func()
1366             return
1368         # see if we're to put up the new node page
1369         m = nre.match(action)
1370         if m:
1371             self.classname = m.group(1)
1372             try:
1373                 func = getattr(self, 'new%s'%self.classname)
1374             except AttributeError:
1375                 raise NotFound, 'new%s'%self.classname
1376             func()
1377             return
1379         # see if we're to put up the new node page
1380         m = sre.match(action)
1381         if m:
1382             self.classname = m.group(1)
1383             try:
1384                 func = getattr(self, 'search%s'%self.classname)
1385             except AttributeError:
1386                 raise NotFound
1387             func()
1388             return
1390         # otherwise, display the named class
1391         self.classname = action
1392         try:
1393             self.db.getclass(self.classname)
1394         except KeyError:
1395             raise NotFound, self.classname
1396         self.list()
1399 class ExtendedClient(Client): 
1400     '''Includes pages and page heading information that relate to the
1401        extended schema.
1402     ''' 
1403     showsupport = Client.shownode
1404     showtimelog = Client.shownode
1405     newsupport = Client.newnode
1406     newtimelog = Client.newnode
1407     searchsupport = Client.searchnode
1409     default_index_sort = ['-activity']
1410     default_index_group = ['priority']
1411     default_index_filter = ['status']
1412     default_index_columns = ['activity','status','title','assignedto']
1413     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1414     default_pagesize = '50'
1416 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1417     '''Pull properties for the given class out of the form.
1418     '''
1419     props = {}
1420     keys = form.keys()
1421     for key in keys:
1422         if not cl.properties.has_key(key):
1423             continue
1424         proptype = cl.properties[key]
1425         if isinstance(proptype, hyperdb.String):
1426             value = form[key].value.strip()
1427         elif isinstance(proptype, hyperdb.Password):
1428             value = password.Password(form[key].value.strip())
1429         elif isinstance(proptype, hyperdb.Date):
1430             value = form[key].value.strip()
1431             if value:
1432                 value = date.Date(form[key].value.strip())
1433             else:
1434                 value = None
1435         elif isinstance(proptype, hyperdb.Interval):
1436             value = form[key].value.strip()
1437             if value:
1438                 value = date.Interval(form[key].value.strip())
1439             else:
1440                 value = None
1441         elif isinstance(proptype, hyperdb.Link):
1442             value = form[key].value.strip()
1443             # see if it's the "no selection" choice
1444             if value == '-1':
1445                 # don't set this property
1446                 continue
1447             else:
1448                 # handle key values
1449                 link = cl.properties[key].classname
1450                 if not num_re.match(value):
1451                     try:
1452                         value = db.classes[link].lookup(value)
1453                     except KeyError:
1454                         raise ValueError, _('property "%(propname)s": '
1455                             '%(value)s not a %(classname)s')%{'propname':key, 
1456                             'value': value, 'classname': link}
1457         elif isinstance(proptype, hyperdb.Multilink):
1458             value = form[key]
1459             if hasattr(value, 'value'):
1460                 # Quite likely to be a FormItem instance
1461                 value = value.value
1462             if not isinstance(value, type([])):
1463                 value = [i.strip() for i in value.value.split(',')]
1464             else:
1465                 value = [i.strip() for i in value]
1466             link = cl.properties[key].classname
1467             l = []
1468             for entry in map(str, value):
1469                 if entry == '': continue
1470                 if not num_re.match(entry):
1471                     try:
1472                         entry = db.classes[link].lookup(entry)
1473                     except KeyError:
1474                         raise ValueError, _('property "%(propname)s": '
1475                             '"%(value)s" not an entry of %(classname)s')%{
1476                             'propname':key, 'value': entry, 'classname': link}
1477                 l.append(entry)
1478             l.sort()
1479             value = l
1481         # get the old value
1482         if nodeid:
1483             try:
1484                 existing = cl.get(nodeid, key)
1485             except KeyError:
1486                 # this might be a new property for which there is no existing
1487                 # value
1488                 if not cl.properties.has_key(key): raise
1490             # if changed, set it
1491             if value != existing:
1492                 props[key] = value
1493         else:
1494             props[key] = value
1495     return props
1498 # $Log: not supported by cvs2svn $
1499 # Revision 1.136  2002/07/10 06:51:08  richard
1500 # . #576241 ] MultiLink problems in parsePropsFromForm
1502 # Revision 1.135  2002/07/10 00:22:34  richard
1503 #  . switched to using a session-based web login
1505 # Revision 1.134  2002/07/09 04:19:09  richard
1506 # Added reindex command to roundup-admin.
1507 # Fixed reindex on first access.
1508 # Also fixed reindexing of entries that change.
1510 # Revision 1.133  2002/07/08 15:32:05  gmcm
1511 # Pagination of index pages.
1512 # New search form.
1514 # Revision 1.132  2002/07/08 07:26:14  richard
1515 # ehem
1517 # Revision 1.131  2002/07/08 06:53:57  richard
1518 # Not sure why the cgi_client had an indexer argument.
1520 # Revision 1.130  2002/06/27 12:01:53  gmcm
1521 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1522 # Some minor optimizations (only compile regexes once).
1524 # Revision 1.129  2002/06/20 23:52:11  richard
1525 # Better handling of unauth attempt to edit stuff
1527 # Revision 1.128  2002/06/12 21:28:25  gmcm
1528 # Allow form to set user-properties on a Fileclass.
1529 # Don't assume that a Fileclass is named "files".
1531 # Revision 1.127  2002/06/11 06:38:24  richard
1532 #  . #565996 ] The "Attach a File to this Issue" fails
1534 # Revision 1.126  2002/05/29 01:16:17  richard
1535 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1536 # though.
1538 # . #541941 ] changing multilink properties by mail
1539 # . #526730 ] search for messages capability
1540 # . #505180 ] split MailGW.handle_Message
1541 #   - also changed cgi client since it was duplicating the functionality
1542 # . build htmlbase if tests are run using CVS checkout (removed note from
1543 #   installation.txt)
1544 # . don't create an empty message on email issue creation if the email is empty
1546 # Revision 1.125  2002/05/25 07:16:24  rochecompaan
1547 # Merged search_indexing-branch with HEAD
1549 # Revision 1.124  2002/05/24 02:09:24  richard
1550 # Nothing like a live demo to show up the bugs ;)
1552 # Revision 1.123  2002/05/22 05:04:13  richard
1553 # Oops
1555 # Revision 1.122  2002/05/22 04:12:05  richard
1556 #  . applied patch #558876 ] cgi client customization
1557 #    ... with significant additions and modifications ;)
1558 #    - extended handling of ML assignedto to all places it's handled
1559 #    - added more NotFound info
1561 # Revision 1.121  2002/05/21 06:08:10  richard
1562 # Handle migration
1564 # Revision 1.120  2002/05/21 06:05:53  richard
1565 #  . #551483 ] assignedto in Client.make_index_link
1567 # Revision 1.119  2002/05/15 06:21:21  richard
1568 #  . node caching now works, and gives a small boost in performance
1570 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1571 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1572 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1573 # (using if __debug__ which is compiled out with -O)
1575 # Revision 1.118  2002/05/12 23:46:33  richard
1576 # ehem, part 2
1578 # Revision 1.117  2002/05/12 23:42:29  richard
1579 # ehem
1581 # Revision 1.116  2002/05/02 08:07:49  richard
1582 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1584 # Revision 1.115  2002/04/02 01:56:10  richard
1585 #  . stop sending blank (whitespace-only) notes
1587 # Revision 1.114.2.4  2002/05/02 11:49:18  rochecompaan
1588 # Allow customization of the search filters that should be displayed
1589 # on the search page.
1591 # Revision 1.114.2.3  2002/04/20 13:23:31  rochecompaan
1592 # We now have a separate search page for nodes.  Search links for
1593 # different classes can be customized in instance_config similar to
1594 # index links.
1596 # Revision 1.114.2.2  2002/04/19 19:54:42  rochecompaan
1597 # cgi_client.py
1598 #     removed search link for the time being
1599 #     moved rendering of matches to htmltemplate
1600 # hyperdb.py
1601 #     filtering of nodes on full text search incorporated in filter method
1602 # roundupdb.py
1603 #     added paramater to call of filter method
1604 # roundup_indexer.py
1605 #     added search method to RoundupIndexer class
1607 # Revision 1.114.2.1  2002/04/03 11:55:57  rochecompaan
1608 #  . Added feature #526730 - search for messages capability
1610 # Revision 1.114  2002/03/17 23:06:05  richard
1611 # oops
1613 # Revision 1.113  2002/03/14 23:59:24  richard
1614 #  . #517734 ] web header customisation is obscure
1616 # Revision 1.112  2002/03/12 22:52:26  richard
1617 # more pychecker warnings removed
1619 # Revision 1.111  2002/02/25 04:32:21  richard
1620 # ahem
1622 # Revision 1.110  2002/02/21 07:19:08  richard
1623 # ... and label, width and height control for extra flavour!
1625 # Revision 1.109  2002/02/21 07:08:19  richard
1626 # oops
1628 # Revision 1.108  2002/02/21 07:02:54  richard
1629 # The correct var is "HTTP_HOST"
1631 # Revision 1.107  2002/02/21 06:57:38  richard
1632 #  . Added popup help for classes using the classhelp html template function.
1633 #    - add <display call="classhelp('priority', 'id,name,description')">
1634 #      to an item page, and it generates a link to a popup window which displays
1635 #      the id, name and description for the priority class. The description
1636 #      field won't exist in most installations, but it will be added to the
1637 #      default templates.
1639 # Revision 1.106  2002/02/21 06:23:00  richard
1640 # *** empty log message ***
1642 # Revision 1.105  2002/02/20 05:52:10  richard
1643 # better error handling
1645 # Revision 1.104  2002/02/20 05:45:17  richard
1646 # Use the csv module for generating the form entry so it's correct.
1647 # [also noted the sf.net feature request id in the change log]
1649 # Revision 1.103  2002/02/20 05:05:28  richard
1650 #  . Added simple editing for classes that don't define a templated interface.
1651 #    - access using the admin "class list" interface
1652 #    - limited to admin-only
1653 #    - requires the csv module from object-craft (url given if it's missing)
1655 # Revision 1.102  2002/02/15 07:08:44  richard
1656 #  . Alternate email addresses are now available for users. See the MIGRATION
1657 #    file for info on how to activate the feature.
1659 # Revision 1.101  2002/02/14 23:39:18  richard
1660 # . All forms now have "double-submit" protection when Javascript is enabled
1661 #   on the client-side.
1663 # Revision 1.100  2002/01/16 07:02:57  richard
1664 #  . lots of date/interval related changes:
1665 #    - more relaxed date format for input
1667 # Revision 1.99  2002/01/16 03:02:42  richard
1668 # #503793 ] changing assignedto resets nosy list
1670 # Revision 1.98  2002/01/14 02:20:14  richard
1671 #  . changed all config accesses so they access either the instance or the
1672 #    config attriubute on the db. This means that all config is obtained from
1673 #    instance_config instead of the mish-mash of classes. This will make
1674 #    switching to a ConfigParser setup easier too, I hope.
1676 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1677 # 0.5.0 switch, I hope!)
1679 # Revision 1.97  2002/01/11 23:22:29  richard
1680 #  . #502437 ] rogue reactor and unittest
1681 #    in short, the nosy reactor was modifying the nosy list. That code had
1682 #    been there for a long time, and I suspsect it was there because we
1683 #    weren't generating the nosy list correctly in other places of the code.
1684 #    We're now doing that, so the nosy-modifying code can go away from the
1685 #    nosy reactor.
1687 # Revision 1.96  2002/01/10 05:26:10  richard
1688 # missed a parsePropsFromForm in last update
1690 # Revision 1.95  2002/01/10 03:39:45  richard
1691 #  . fixed some problems with web editing and change detection
1693 # Revision 1.94  2002/01/09 13:54:21  grubert
1694 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
1696 # Revision 1.93  2002/01/08 11:57:12  richard
1697 # crying out for real configuration handling... :(
1699 # Revision 1.92  2002/01/08 04:12:05  richard
1700 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
1702 # Revision 1.91  2002/01/08 04:03:47  richard
1703 # I mucked the intent of the code up.
1705 # Revision 1.90  2002/01/08 03:56:55  richard
1706 # Oops, missed this before the beta:
1707 #  . #495392 ] empty nosy -patch
1709 # Revision 1.89  2002/01/07 20:24:45  richard
1710 # *mutter* stupid cutnpaste
1712 # Revision 1.88  2002/01/02 02:31:38  richard
1713 # Sorry for the huge checkin message - I was only intending to implement #496356
1714 # but I found a number of places where things had been broken by transactions:
1715 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1716 #    for _all_ roundup-generated smtp messages to be sent to.
1717 #  . the transaction cache had broken the roundupdb.Class set() reactors
1718 #  . newly-created author users in the mailgw weren't being committed to the db
1720 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1721 # on when I found that stuff :):
1722 #  . #496356 ] Use threading in messages
1723 #  . detectors were being registered multiple times
1724 #  . added tests for mailgw
1725 #  . much better attaching of erroneous messages in the mail gateway
1727 # Revision 1.87  2001/12/23 23:18:49  richard
1728 # We already had an admin-specific section of the web heading, no need to add
1729 # another one :)
1731 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
1732 # Features added:
1733 #  .  Multilink properties are now displayed as comma separated values in
1734 #     a textbox
1735 #  .  The add user link is now only visible to the admin user
1736 #  .  Modified the mail gateway to reject submissions from unknown
1737 #     addresses if ANONYMOUS_ACCESS is denied
1739 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
1740 # Bugs fixed:
1741 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1742 #     lost somewhere
1743 #   . Internet Explorer submits full path for filename - we now strip away
1744 #     the path
1745 # Features added:
1746 #   . Link and multilink properties are now displayed sorted in the cgi
1747 #     interface
1749 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
1750 # Fixed bugs:
1751 #  .  Fixed file creation and retrieval in same transaction in anydbm
1752 #     backend
1753 #  .  Cgi interface now renders new issue after issue creation
1754 #  .  Could not set issue status to resolved through cgi interface
1755 #  .  Mail gateway was changing status back to 'chatting' if status was
1756 #     omitted as an argument
1758 # Revision 1.83  2001/12/15 23:51:01  richard
1759 # Tested the changes and fixed a few problems:
1760 #  . files are now attached to the issue as well as the message
1761 #  . newuser is a real method now since we don't want to do the message/file
1762 #    stuff for it
1763 #  . added some documentation
1764 # The really big changes in the diff are a result of me moving some code
1765 # around to keep like methods together a bit better.
1767 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
1768 #  . Modified cgi interface to change properties only once all changes are
1769 #    collected, files created and messages generated.
1770 #  . Moved generation of change note to nosyreactors.
1771 #  . We now check for changes to "assignedto" to ensure it's added to the
1772 #    nosy list.
1774 # Revision 1.81  2001/12/12 23:55:00  richard
1775 # Fixed some problems with user editing
1777 # Revision 1.80  2001/12/12 23:27:14  richard
1778 # Added a Zope frontend for roundup.
1780 # Revision 1.79  2001/12/10 22:20:01  richard
1781 # Enabled transaction support in the bsddb backend. It uses the anydbm code
1782 # where possible, only replacing methods where the db is opened (it uses the
1783 # btree opener specifically.)
1784 # Also cleaned up some change note generation.
1785 # Made the backends package work with pydoc too.
1787 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
1788 # Fixed small bug that prevented adding issues through the web.
1790 # Revision 1.77  2001/12/06 22:48:29  richard
1791 # files multilink was being nuked in post_edit_node
1793 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
1794 # Removed generation of change note from "sendmessage" in roundupdb.py.
1795 # The change note is now generated when the message is created.
1797 # Revision 1.75  2001/12/04 01:25:08  richard
1798 # Added some rollbacks where we were catching exceptions that would otherwise
1799 # have stopped committing.
1801 # Revision 1.74  2001/12/02 05:06:16  richard
1802 # . We now use weakrefs in the Classes to keep the database reference, so
1803 #   the close() method on the database is no longer needed.
1804 #   I bumped the minimum python requirement up to 2.1 accordingly.
1805 # . #487480 ] roundup-server
1806 # . #487476 ] INSTALL.txt
1808 # I also cleaned up the change message / post-edit stuff in the cgi client.
1809 # There's now a clearly marked "TODO: append the change note" where I believe
1810 # the change note should be added there. The "changes" list will obviously
1811 # have to be modified to be a dict of the changes, or somesuch.
1813 # More testing needed.
1815 # Revision 1.73  2001/12/01 07:17:50  richard
1816 # . We now have basic transaction support! Information is only written to
1817 #   the database when the commit() method is called. Only the anydbm
1818 #   backend is modified in this way - neither of the bsddb backends have been.
1819 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1820 #   doesn't have a commit command, so interactive users can't commit...)
1821 # . Fixed login/registration forwarding the user to the right page (or not,
1822 #   on a failure)
1824 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
1825 # Links in page header are now consistent with default sort order.
1827 # Fixed bugs:
1828 #     - When login failed the list of issues were still rendered.
1829 #     - User was redirected to index page and not to his destination url
1830 #       if his first login attempt failed.
1832 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
1833 # Property changes are now completely traceable, whether changes are
1834 # made through the web or by email
1836 # Revision 1.70  2001/11/30 00:06:29  richard
1837 # Converted roundup/cgi_client.py to use _()
1838 # Added the status file, I18N_PROGRESS.txt
1840 # Revision 1.69  2001/11/29 23:19:51  richard
1841 # Removed the "This issue has been edited through the web" when a valid
1842 # change note is supplied.
1844 # Revision 1.68  2001/11/29 04:57:23  richard
1845 # a little comment
1847 # Revision 1.67  2001/11/28 21:55:35  richard
1848 #  . login_action and newuser_action return values were being ignored
1849 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1850 #    gateway.
1851 #  (also a minor cleanup in hyperdb)
1853 # Revision 1.66  2001/11/27 03:00:50  richard
1854 # couple of bugfixes from latest patch integration
1856 # Revision 1.65  2001/11/26 23:00:53  richard
1857 # This config stuff is getting to be a real mess...
1859 # Revision 1.64  2001/11/26 22:56:35  richard
1860 # typo
1862 # Revision 1.63  2001/11/26 22:55:56  richard
1863 # Feature:
1864 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1865 #    the instance.
1866 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1867 #    signature info in e-mails.
1868 #  . Some more flexibility in the mail gateway and more error handling.
1869 #  . Login now takes you to the page you back to the were denied access to.
1871 # Fixed:
1872 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1874 # Revision 1.62  2001/11/24 00:45:42  jhermann
1875 # typeof() instead of type(): avoid clash with database field(?) "type"
1877 # Fixes this traceback:
1879 # Traceback (most recent call last):
1880 #   File "roundup\cgi_client.py", line 535, in newnode
1881 #     self._post_editnode(nid)
1882 #   File "roundup\cgi_client.py", line 415, in _post_editnode
1883 #     if type(value) != type([]): value = [value]
1884 # UnboundLocalError: local variable 'type' referenced before assignment
1886 # Revision 1.61  2001/11/22 15:46:42  jhermann
1887 # Added module docstrings to all modules.
1889 # Revision 1.60  2001/11/21 22:57:28  jhermann
1890 # Added dummy hooks for I18N and some preliminary (test) markup of
1891 # translatable messages
1893 # Revision 1.59  2001/11/21 03:21:13  richard
1894 # oops
1896 # Revision 1.58  2001/11/21 03:11:28  richard
1897 # Better handling of new properties.
1899 # Revision 1.57  2001/11/15 10:24:27  richard
1900 # handle the case where there is no file attached
1902 # Revision 1.56  2001/11/14 21:35:21  richard
1903 #  . users may attach files to issues (and support in ext) through the web now
1905 # Revision 1.55  2001/11/07 02:34:06  jhermann
1906 # Handling of damaged login cookies
1908 # Revision 1.54  2001/11/07 01:16:12  richard
1909 # Remove the '=' padding from cookie value so quoting isn't an issue.
1911 # Revision 1.53  2001/11/06 23:22:05  jhermann
1912 # More IE fixes: it does not like quotes around cookie values; in the
1913 # hope this does not break anything for other browser; if it does, we
1914 # need to check HTTP_USER_AGENT
1916 # Revision 1.52  2001/11/06 23:11:22  jhermann
1917 # Fixed debug output in page footer; added expiry date to the login cookie
1918 # (expires 1 year in the future) to prevent probs with certain versions
1919 # of IE
1921 # Revision 1.51  2001/11/06 22:00:34  jhermann
1922 # Get debug level from ROUNDUP_DEBUG env var
1924 # Revision 1.50  2001/11/05 23:45:40  richard
1925 # Fixed newuser_action so it sets the cookie with the unencrypted password.
1926 # Also made it present nicer error messages (not tracebacks).
1928 # Revision 1.49  2001/11/04 03:07:12  richard
1929 # Fixed various cookie-related bugs:
1930 #  . bug #477685 ] base64.decodestring breaks
1931 #  . bug #477837 ] lynx does not like the cookie
1932 #  . bug #477892 ] Password edit doesn't fix login cookie
1933 # Also closed a security hole - a logged-in user could edit another user's
1934 # details.
1936 # Revision 1.48  2001/11/03 01:30:18  richard
1937 # Oops. uses pagefoot now.
1939 # Revision 1.47  2001/11/03 01:29:28  richard
1940 # Login page didn't have all close tags.
1942 # Revision 1.46  2001/11/03 01:26:55  richard
1943 # possibly fix truncated base64'ed user:pass
1945 # Revision 1.45  2001/11/01 22:04:37  richard
1946 # Started work on supporting a pop3-fetching server
1947 # Fixed bugs:
1948 #  . bug #477104 ] HTML tag error in roundup-server
1949 #  . bug #477107 ] HTTP header problem
1951 # Revision 1.44  2001/10/28 23:03:08  richard
1952 # Added more useful header to the classic schema.
1954 # Revision 1.43  2001/10/24 00:01:42  richard
1955 # More fixes to lockout logic.
1957 # Revision 1.42  2001/10/23 23:56:03  richard
1958 # HTML typo
1960 # Revision 1.41  2001/10/23 23:52:35  richard
1961 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
1963 # Revision 1.40  2001/10/23 23:06:39  richard
1964 # Some cleanup.
1966 # Revision 1.39  2001/10/23 01:00:18  richard
1967 # Re-enabled login and registration access after lopping them off via
1968 # disabling access for anonymous users.
1969 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1970 # a couple of bugs while I was there. Probably introduced a couple, but
1971 # things seem to work OK at the moment.
1973 # Revision 1.38  2001/10/22 03:25:01  richard
1974 # Added configuration for:
1975 #  . anonymous user access and registration (deny/allow)
1976 #  . filter "widget" location on index page (top, bottom, both)
1977 # Updated some documentation.
1979 # Revision 1.37  2001/10/21 07:26:35  richard
1980 # feature #473127: Filenames. I modified the file.index and htmltemplate
1981 #  source so that the filename is used in the link and the creation
1982 #  information is displayed.
1984 # Revision 1.36  2001/10/21 04:44:50  richard
1985 # bug #473124: UI inconsistency with Link fields.
1986 #    This also prompted me to fix a fairly long-standing usability issue -
1987 #    that of being able to turn off certain filters.
1989 # Revision 1.35  2001/10/21 00:17:54  richard
1990 # CGI interface view customisation section may now be hidden (patch from
1991 #  Roch'e Compaan.)
1993 # Revision 1.34  2001/10/20 11:58:48  richard
1994 # Catch errors in login - no username or password supplied.
1995 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
1997 # Revision 1.33  2001/10/17 00:18:41  richard
1998 # Manually constructing cookie headers now.
2000 # Revision 1.32  2001/10/16 03:36:21  richard
2001 # CGI interface wasn't handling checkboxes at all.
2003 # Revision 1.31  2001/10/14 10:55:00  richard
2004 # Handle empty strings in HTML template Link function
2006 # Revision 1.30  2001/10/09 07:38:58  richard
2007 # Pushed the base code for the extended schema CGI interface back into the
2008 # code cgi_client module so that future updates will be less painful.
2009 # Also removed a debugging print statement from cgi_client.
2011 # Revision 1.29  2001/10/09 07:25:59  richard
2012 # Added the Password property type. See "pydoc roundup.password" for
2013 # implementation details. Have updated some of the documentation too.
2015 # Revision 1.28  2001/10/08 00:34:31  richard
2016 # Change message was stuffing up for multilinks with no key property.
2018 # Revision 1.27  2001/10/05 02:23:24  richard
2019 #  . roundup-admin create now prompts for property info if none is supplied
2020 #    on the command-line.
2021 #  . hyperdb Class getprops() method may now return only the mutable
2022 #    properties.
2023 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
2024 #    now support anonymous user access (read-only, unless there's an
2025 #    "anonymous" user, in which case write access is permitted). Login
2026 #    handling has been moved into cgi_client.Client.main()
2027 #  . The "extended" schema is now the default in roundup init.
2028 #  . The schemas have had their page headings modified to cope with the new
2029 #    login handling. Existing installations should copy the interfaces.py
2030 #    file from the roundup lib directory to their instance home.
2031 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
2032 #    Ping - has been removed.
2033 #  . Fixed a whole bunch of places in the CGI interface where we should have
2034 #    been returning Not Found instead of throwing an exception.
2035 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
2036 #    an item now throws an exception.
2038 # Revision 1.26  2001/09/12 08:31:42  richard
2039 # handle cases where mime type is not guessable
2041 # Revision 1.25  2001/08/29 05:30:49  richard
2042 # change messages weren't being saved when there was no-one on the nosy list.
2044 # Revision 1.24  2001/08/29 04:49:39  richard
2045 # didn't clean up fully after debugging :(
2047 # Revision 1.23  2001/08/29 04:47:18  richard
2048 # Fixed CGI client change messages so they actually include the properties
2049 # changed (again).
2051 # Revision 1.22  2001/08/17 00:08:10  richard
2052 # reverted back to sending messages always regardless of who is doing the web
2053 # edit. change notes weren't being saved. bleah. hackish.
2055 # Revision 1.21  2001/08/15 23:43:18  richard
2056 # Fixed some isFooTypes that I missed.
2057 # Refactored some code in the CGI code.
2059 # Revision 1.20  2001/08/12 06:32:36  richard
2060 # using isinstance(blah, Foo) now instead of isFooType
2062 # Revision 1.19  2001/08/07 00:24:42  richard
2063 # stupid typo
2065 # Revision 1.18  2001/08/07 00:15:51  richard
2066 # Added the copyright/license notice to (nearly) all files at request of
2067 # Bizar Software.
2069 # Revision 1.17  2001/08/02 06:38:17  richard
2070 # Roundupdb now appends "mailing list" information to its messages which
2071 # include the e-mail address and web interface address. Templates may
2072 # override this in their db classes to include specific information (support
2073 # instructions, etc).
2075 # Revision 1.16  2001/08/02 05:55:25  richard
2076 # Web edit messages aren't sent to the person who did the edit any more. No
2077 # message is generated if they are the only person on the nosy list.
2079 # Revision 1.15  2001/08/02 00:34:10  richard
2080 # bleah syntax error
2082 # Revision 1.14  2001/08/02 00:26:16  richard
2083 # Changed the order of the information in the message generated by web edits.
2085 # Revision 1.13  2001/07/30 08:12:17  richard
2086 # Added time logging and file uploading to the templates.
2088 # Revision 1.12  2001/07/30 06:26:31  richard
2089 # Added some documentation on how the newblah works.
2091 # Revision 1.11  2001/07/30 06:17:45  richard
2092 # Features:
2093 #  . Added ability for cgi newblah forms to indicate that the new node
2094 #    should be linked somewhere.
2095 # Fixed:
2096 #  . Fixed the agument handling for the roundup-admin find command.
2097 #  . Fixed handling of summary when no note supplied for newblah. Again.
2098 #  . Fixed detection of no form in htmltemplate Field display.
2100 # Revision 1.10  2001/07/30 02:37:34  richard
2101 # Temporary measure until we have decent schema migration...
2103 # Revision 1.9  2001/07/30 01:25:07  richard
2104 # Default implementation is now "classic" rather than "extended" as one would
2105 # expect.
2107 # Revision 1.8  2001/07/29 08:27:40  richard
2108 # Fixed handling of passed-in values in form elements (ie. during a
2109 # drill-down)
2111 # Revision 1.7  2001/07/29 07:01:39  richard
2112 # Added vim command to all source so that we don't get no steenkin' tabs :)
2114 # Revision 1.6  2001/07/29 04:04:00  richard
2115 # Moved some code around allowing for subclassing to change behaviour.
2117 # Revision 1.5  2001/07/28 08:16:52  richard
2118 # New issue form handles lack of note better now.
2120 # Revision 1.4  2001/07/28 00:34:34  richard
2121 # Fixed some non-string node ids.
2123 # Revision 1.3  2001/07/23 03:56:30  richard
2124 # oops, missed a config removal
2126 # Revision 1.2  2001/07/22 12:09:32  richard
2127 # Final commit of Grande Splite
2129 # Revision 1.1  2001/07/22 11:58:35  richard
2130 # More Grande Splite
2133 # vim: set filetype=python ts=4 sw=4 et si