Code

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