Code

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