Code

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