Code

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