Code

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