Code

cleanup
[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.158 2002-08-15 00:40:10 richard Exp $
20 __doc__ = """
21 WWW request handler (also used in the stand-alone server).
22 """
24 import os, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
25 import binascii, Cookie, time, random
27 import roundupdb, htmltemplate, date, hyperdb, password
28 from roundup.i18n import _
30 class Unauthorised(ValueError):
31     pass
33 class NotFound(ValueError):
34     pass
36 def initialiseSecurity(security):
37     ''' Create some Permissions and Roles on the security object
39         This function is directly invoked by security.Security.__init__()
40         as a part of the Security object instantiation.
41     '''
42     security.addPermission(name="Web Registration",
43         description="User may register through the web")
44     security.addPermission(name="Web Access",
45         description="User may access the web interface")
47     # doing Role stuff through the web - make sure Admin can
48     p = security.addPermission(name="Web Roles",
49         description="User may manipulate user Roles through the web")
50     security.addPermissionToRole('Admin', p)
52 class Client:
53     '''
54     A note about login
55     ------------------
57     If the user has no login cookie, then they are anonymous. There
58     are two levels of anonymous use. If there is no 'anonymous' user, there
59     is no login at all and the database is opened in read-only mode. If the
60     'anonymous' user exists, the user is logged in using that user (though
61     there is no cookie). This allows them to modify the database, and all
62     modifications are attributed to the 'anonymous' user.
64     Once a user logs in, they are assigned a session. The Client instance
65     keeps the nodeid of the session as the "session" attribute.
66     '''
68     def __init__(self, instance, request, env, form=None):
69         hyperdb.traceMark()
70         self.instance = instance
71         self.request = request
72         self.env = env
73         self.path = env['PATH_INFO']
74         self.split_path = self.path.split('/')
75         self.instance_path_name = env['INSTANCE_NAME']
76         url = self.env['SCRIPT_NAME'] + '/'
77         machine = self.env['SERVER_NAME']
78         port = self.env['SERVER_PORT']
79         if port != '80': machine = machine + ':' + port
80         self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
81             None, None, None))
83         if form is None:
84             self.form = cgi.FieldStorage(environ=env)
85         else:
86             self.form = form
87         self.headers_done = 0
88         try:
89             self.debug = int(env.get("ROUNDUP_DEBUG", 0))
90         except ValueError:
91             # someone gave us a non-int debug level, turn it off
92             self.debug = 0
94     def getuid(self):
95         try:
96             return self.db.user.lookup(self.user)
97         except KeyError:
98             if self.user is None:
99                 # user is not logged in and username 'anonymous' doesn't
100                 # exist in the database
101                 err = _('anonymous users have read-only access only')
102             else:
103                 err = _("sanity check: unknown user name `%s'")%self.user
104             raise Unauthorised, errmsg
106     def header(self, headers=None, response=200):
107         '''Put up the appropriate header.
108         '''
109         if headers is None:
110             headers = {'Content-Type':'text/html'}
111         if not headers.has_key('Content-Type'):
112             headers['Content-Type'] = 'text/html'
113         self.request.send_response(response)
114         for entry in headers.items():
115             self.request.send_header(*entry)
116         self.request.end_headers()
117         self.headers_done = 1
118         if self.debug:
119             self.headers_sent = headers
121     global_javascript = '''
122 <script language="javascript">
123 submitted = false;
124 function submit_once() {
125     if (submitted) {
126         alert("Your request is being processed.\\nPlease be patient.");
127         return 0;
128     }
129     submitted = true;
130     return 1;
133 function help_window(helpurl, width, height) {
134     HelpWin = window.open('%(base)s%(instance_path_name)s/' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
137 </script>
138 '''
139     def make_index_link(self, name):
140         '''Turn a configuration entry into a hyperlink...
141         '''
142         # get the link label and spec
143         spec = getattr(self.instance, name+'_INDEX')
145         d = {}
146         d[':sort'] = ','.join(map(urllib.quote, spec['SORT']))
147         d[':group'] = ','.join(map(urllib.quote, spec['GROUP']))
148         d[':filter'] = ','.join(map(urllib.quote, spec['FILTER']))
149         d[':columns'] = ','.join(map(urllib.quote, spec['COLUMNS']))
150         d[':pagesize'] = spec.get('PAGESIZE','50')
152         # snarf the filterspec
153         filterspec = spec['FILTERSPEC'].copy()
155         # now format the filterspec
156         for k, l in filterspec.items():
157             # fix up the CURRENT USER if needed (handle None too since that's
158             # the old flag value)
159             if l in (None, 'CURRENT USER'):
160                 if not self.user:
161                     continue
162                 l = [self.db.user.lookup(self.user)]
164             # add
165             d[urllib.quote(k)] = ','.join(map(urllib.quote, l))
167         # finally, format the URL
168         return '<a href="%s?%s">%s</a>'%(spec['CLASS'],
169             '&'.join([k+'='+v for k,v in d.items()]), spec['LABEL'])
172     def pagehead(self, title, message=None):
173         '''Display the page heading, with information about the tracker and
174             links to more information
175         '''
177         # include any important message
178         if message is not None:
179             message = _('<div class="system-msg">%(message)s</div>')%locals()
180         else:
181             message = ''
183         # style sheet (CSS)
184         style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
186         # figure who the user is
187         user_name = self.user
188         userid = self.db.user.lookup(user_name)
189         default_queries = 1
190         links = []
191         if user_name != 'anonymous':
192             try:
193                 default_queries = self.db.user.get(userid, 'defaultqueries')
194             except KeyError:
195                 pass
197         # figure all the header links
198         if default_queries:
199             if hasattr(self.instance, 'HEADER_INDEX_LINKS'):
200                 for name in self.instance.HEADER_INDEX_LINKS:
201                     spec = getattr(self.instance, name + '_INDEX')
202                     # skip if we need to fill in the logged-in user id and
203                     # we're anonymous
204                     if (spec['FILTERSPEC'].has_key('assignedto') and
205                             spec['FILTERSPEC']['assignedto'] in ('CURRENT USER',
206                             None) and user_name == 'anonymous'):
207                         continue
208                     links.append(self.make_index_link(name))
209             else:
210                 # no config spec - hard-code
211                 links = [
212                     _('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>'),
213                     _('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>')
214                 ]
216         user_info = _('<a href="login">Login</a>')
217         add_links = ''
218         if user_name != 'anonymous':
219             # add any personal queries to the menu
220             try:
221                 queries = self.db.getclass('query')
222             except KeyError:
223                 # no query class
224                 queries = self.instance.dbinit.Class(self.db, "query",
225                     klass=hyperdb.String(), name=hyperdb.String(),
226                     url=hyperdb.String())
227                 queries.setkey('name')
228                 #queries.disableJournalling()
229             try:
230                 qids = self.db.getclass('user').get(userid, 'queries')
231             except KeyError, e:
232                 #self.db.getclass('user').addprop(queries=hyperdb.Multilink('query'))
233                 qids = []
234             for qid in qids:
235                 links.append('<a href=%s?%s>%s</a>'%(queries.get(qid, 'klass'),
236                     queries.get(qid, 'url'), queries.get(qid, 'name')))
238             # if they're logged in, include links to their information,
239             # and the ability to add an issue
240             user_info = _('''
241 <a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
242 ''')%locals()
244         # figure the "add class" links
245         if hasattr(self.instance, 'HEADER_ADD_LINKS'):
246             classes = self.instance.HEADER_ADD_LINKS
247         else:
248             classes = ['issue']
249         l = []
250         for class_name in classes:
251             # make sure the user has permission to add
252             if not self.db.security.hasPermission('Edit', userid, class_name):
253                 continue
254             cap_class = class_name.capitalize()
255             links.append(_('Add <a href="new%(class_name)s">'
256                 '%(cap_class)s</a>')%locals())
258         # if the user can edit everything, include the links
259         admin_links = ''
260         userid = self.db.user.lookup(user_name)
261         if self.db.security.hasPermission('Edit', userid):
262             links.append(_('<a href="list_classes">Class List</a>'))
263             links.append(_('<a href="user?:sort=username&:group=roles">User List</a>'))
264             links.append(_('<a href="newuser">Add User</a>'))
266         # add the search links
267         if hasattr(self.instance, 'HEADER_SEARCH_LINKS'):
268             classes = self.instance.HEADER_SEARCH_LINKS
269         else:
270             classes = ['issue']
271         l = []
272         for class_name in classes:
273             # make sure the user has permission to view
274             if not self.db.security.hasPermission('View', userid, class_name):
275                 continue
276             cap_class = class_name.capitalize()
277             links.append(_('Search <a href="search%(class_name)s">'
278                 '%(cap_class)s</a>')%locals())
280         # now we have all the links, join 'em
281         links = '\n | '.join(links)
283         # include the javascript bit
284         global_javascript = self.global_javascript%self.__dict__
286         # finally, format the header
287         self.write(_('''<html><head>
288 <title>%(title)s</title>
289 <style type="text/css">%(style)s</style>
290 </head>
291 %(global_javascript)s
292 <body bgcolor=#ffffff>
293 %(message)s
294 <table width=100%% border=0 cellspacing=0 cellpadding=2>
295  <tr class="location-bar">
296   <td><big><strong>%(title)s</strong></big></td>
297   <td align=right valign=bottom>%(user_name)s</td>
298  </tr>
299  <tr class="location-bar">
300   <td align=left>%(links)s</td>
301   <td align=right>%(user_info)s</td>
302  </tr>
303 </table><br>
304 ''')%locals())
306     def pagefoot(self):
307         if self.debug:
308             self.write(_('<hr><small><dl><dt><b>Path</b></dt>'))
309             self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
310             keys = self.form.keys()
311             keys.sort()
312             if keys:
313                 self.write(_('<dt><b>Form entries</b></dt>'))
314                 for k in self.form.keys():
315                     v = self.form.getvalue(k, "<empty>")
316                     if type(v) is type([]):
317                         # Multiple username fields specified
318                         v = "|".join(v)
319                     self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
320             keys = self.headers_sent.keys()
321             keys.sort()
322             self.write(_('<dt><b>Sent these HTTP headers</b></dt>'))
323             for k in keys:
324                 v = self.headers_sent[k]
325                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
326             keys = self.env.keys()
327             keys.sort()
328             self.write(_('<dt><b>CGI environment</b></dt>'))
329             for k in keys:
330                 v = self.env[k]
331                 self.write('<dd><em>%s</em>=%s</dd>'%(k, cgi.escape(v)))
332             self.write('</dl></small>')
333         self.write('</body></html>')
335     def write(self, content):
336         if not self.headers_done:
337             self.header()
338         self.request.wfile.write(content)
340     def index_arg(self, arg):
341         ''' handle the args to index - they might be a list from the form
342             (ie. submitted from a form) or they might be a command-separated
343             single string (ie. manually constructed GET args)
344         '''
345         if self.form.has_key(arg):
346             arg =  self.form[arg]
347             if type(arg) == type([]):
348                 return [arg.value for arg in arg]
349             return arg.value.split(',')
350         return []
352     def index_sort(self):
353         # first try query string / simple form
354         x = self.index_arg(':sort')
355         if x:
356             if self.index_arg(':descending'):
357                 return ['-'+x[0]]
358             return x
359         # nope - get the specs out of the form
360         specs = []
361         for colnm in self.db.getclass(self.classname).getprops().keys():
362             desc = ''
363             try:
364                 spec = self.form[':%s_ss' % colnm]
365             except KeyError:
366                 continue
367             spec = spec.value
368             if spec:
369                 if spec[-1] == '-':
370                     desc='-'
371                     spec = spec[0]
372                 specs.append((int(spec), colnm, desc))
373         specs.sort()
374         x = []
375         for _, colnm, desc in specs:
376             x.append('%s%s' % (desc, colnm))
377         return x
378     
379     def index_filterspec(self, filter, classname=None):
380         ''' pull the index filter spec from the form
382         Links and multilinks want to be lists - the rest are straight
383         strings.
384         '''
385         if classname is None:
386             classname = self.classname
387         klass = self.db.getclass(classname)
388         filterspec = {}
389         props = klass.getprops()
390         for colnm in filter:
391             widget = ':%s_fs' % colnm
392             try:
393                 val = self.form[widget]
394             except KeyError:
395                 try:
396                     val = self.form[colnm]
397                 except KeyError:
398                     # they checked the filter box but didn't enter a value
399                     continue
400             propdescr = props.get(colnm, None)
401             if propdescr is None:
402                 print "huh? %r is in filter & form, but not in Class!" % colnm
403                 raise "butthead programmer"
404             if (isinstance(propdescr, hyperdb.Link) or
405                 isinstance(propdescr, hyperdb.Multilink)):
406                 if type(val) == type([]):
407                     val = [arg.value for arg in val]
408                 else:
409                     val = val.value.split(',')
410                 l = filterspec.get(colnm, [])
411                 l = l + val
412                 filterspec[colnm] = l
413             else:
414                 filterspec[colnm] = val.value
415             
416         return filterspec
417     
418     def customization_widget(self):
419         ''' The customization widget is visible by default. The widget
420             visibility is remembered by show_customization.  Visibility
421             is not toggled if the action value is "Redisplay"
422         '''
423         if not self.form.has_key('show_customization'):
424             visible = 1
425         else:
426             visible = int(self.form['show_customization'].value)
427             if self.form.has_key('action'):
428                 if self.form['action'].value != 'Redisplay':
429                     visible = self.form['action'].value == '+'
430             
431         return visible
433     # TODO: make this go away some day...
434     default_index_sort = ['-activity']
435     default_index_group = ['priority']
436     default_index_filter = ['status']
437     default_index_columns = ['id','activity','title','status','assignedto']
438     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
439     default_pagesize = '50'
441     def _get_customisation_info(self):
442         # see if the web has supplied us with any customisation info
443         for key in ':sort', ':group', ':filter', ':columns', ':pagesize':
444             if self.form.has_key(key):
445                 # make list() extract the info from the CGI environ
446                 self.classname = 'issue'
447                 sort = group = filter = columns = filterspec = pagesize = None
448                 break
449         else:
450             # TODO: look up the session first
451             # try the instance config first
452             if hasattr(self.instance, 'DEFAULT_INDEX'):
453                 d = self.instance.DEFAULT_INDEX
454                 self.classname = d['CLASS']
455                 sort = d['SORT']
456                 group = d['GROUP']
457                 filter = d['FILTER']
458                 columns = d['COLUMNS']
459                 filterspec = d['FILTERSPEC']
460                 pagesize = d.get('PAGESIZE', '50')
461             else:
462                 # nope - fall back on the old way of doing it
463                 self.classname = 'issue'
464                 sort = self.default_index_sort
465                 group = self.default_index_group
466                 filter = self.default_index_filter
467                 columns = self.default_index_columns
468                 filterspec = self.default_index_filterspec
469                 pagesize = self.default_pagesize
470         return columns, filter, group, sort, filterspec, pagesize
472     def index(self):
473         ''' put up an index - no class specified
474         '''
475         columns, filter, group, sort, filterspec, pagesize = \
476             self._get_customisation_info()
477         return self.list(columns=columns, filter=filter, group=group,
478             sort=sort, filterspec=filterspec, pagesize=pagesize)
480     def searchnode(self):
481         columns, filter, group, sort, filterspec, pagesize = \
482             self._get_customisation_info()
483         cn = self.classname
484         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
485             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
486         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
487         self.write('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
488         all_columns = self.db.getclass(cn).getprops().keys()
489         all_columns.sort()
490         index.filter_section('', filter, columns, group, all_columns, sort,
491                              filterspec, pagesize, 0, 0)
492         self.pagefoot()
494     # XXX deviates from spec - loses the '+' (that's a reserved character
495     # in URLS
496     def list(self, sort=None, group=None, filter=None, columns=None,
497             filterspec=None, show_customization=None, show_nodes=1,
498             pagesize=None):
499         ''' call the template index with the args
501             :sort    - sort by prop name, optionally preceeded with '-'
502                      to give descending or nothing for ascending sorting.
503             :group   - group by prop name, optionally preceeded with '-' or
504                      to sort in descending or nothing for ascending order.
505             :filter  - selects which props should be displayed in the filter
506                      section. Default is all.
507             :columns - selects the columns that should be displayed.
508                      Default is all.
510         '''
511         cn = self.classname
512         cl = self.db.classes[cn]
513         if sort is None: sort = self.index_sort()
514         if group is None: group = self.index_arg(':group')
515         if filter is None: filter = self.index_arg(':filter')
516         if columns is None: columns = self.index_arg(':columns')
517         if filterspec is None: filterspec = self.index_filterspec(filter)
518         if show_customization is None:
519             show_customization = self.customization_widget()
520         if self.form.has_key('search_text'):
521             search_text = self.form['search_text'].value
522         else:
523             search_text = ''
524         if pagesize is None:
525             if self.form.has_key(':pagesize'):
526                 pagesize = self.form[':pagesize'].value
527             else:
528                 pagesize = '50'
529         pagesize = int(pagesize)
530         if self.form.has_key(':startwith'):
531             startwith = int(self.form[':startwith'].value)
532         else:
533             startwith = 0
534         simpleform = 1
535         if self.form.has_key(':advancedsearch'):
536             simpleform = 0
538         if self.form.has_key('Query') and self.form['Query'].value == 'Save':
539             # format a query string
540             qd = {}
541             qd[':sort'] = ','.join(map(urllib.quote, sort))
542             qd[':group'] = ','.join(map(urllib.quote, group))
543             qd[':filter'] = ','.join(map(urllib.quote, filter))
544             qd[':columns'] = ','.join(map(urllib.quote, columns))
545             for k, l in filterspec.items():
546                 qd[urllib.quote(k)] = ','.join(map(urllib.quote, l))
547             url = '&'.join([k+'='+v for k,v in qd.items()])
548             url += '&:pagesize=%s' % pagesize
549             if search_text:
550                 url += '&search_text=%s' % search_text
552             # create a query
553             d = {}
554             d['name'] = nm = self.form[':name'].value
555             if not nm:
556                 d['name'] = nm = 'New Query'
557             d['klass'] = self.form[':classname'].value
558             d['url'] = url
559             qid = self.db.getclass('query').create(**d)
561             # and add it to the user's query multilink
562             uid = self.getuid()
563             usercl = self.db.getclass('user')
564             queries = usercl.get(uid, 'queries')
565             queries.append(qid)
566             usercl.set(uid, queries=queries)
567             
568         self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
569             'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
570         
571         index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
572         try:
573             index.render(filterspec, search_text, filter, columns, sort, 
574                 group, show_customization=show_customization, 
575                 show_nodes=show_nodes, pagesize=pagesize, startwith=startwith,
576                 simple_search=simpleform)
577         except htmltemplate.MissingTemplateError:
578             self.basicClassEditPage()
579         self.pagefoot()
581     def basicClassEditPage(self):
582         '''Display a basic edit page that allows simple editing of the
583            nodes of the current class
584         '''
585         userid = self.db.user.lookup(self.user)
586         if not self.db.security.hasPermission('Edit', userid):
587             raise Unauthorised, _("You do not have permission to access"\
588                         " %(action)s.")%{'action': self.classname}
589         w = self.write
590         cn = self.classname
591         cl = self.db.classes[cn]
592         idlessprops = cl.getprops(protected=0).keys()
593         props = ['id'] + idlessprops
595         # get the CSV module
596         try:
597             import csv
598         except ImportError:
599             w(_('Sorry, you need the csv module to use this function.<br>\n'
600                 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
601             return
603         # do the edit
604         if self.form.has_key('rows'):
605             rows = self.form['rows'].value.splitlines()
606             p = csv.parser()
607             found = {}
608             line = 0
609             for row in rows:
610                 line += 1
611                 values = p.parse(row)
612                 # not a complete row, keep going
613                 if not values: continue
615                 # extract the nodeid
616                 nodeid, values = values[0], values[1:]
617                 found[nodeid] = 1
619                 # confirm correct weight
620                 if len(idlessprops) != len(values):
621                     w(_('Not enough values on line %(line)s'%{'line':line}))
622                     return
624                 # extract the new values
625                 d = {}
626                 for name, value in zip(idlessprops, values):
627                     value = value.strip()
628                     # only add the property if it has a value
629                     if value:
630                         # if it's a multilink, split it
631                         if isinstance(cl.properties[name], hyperdb.Multilink):
632                             value = value.split(':')
633                         d[name] = value
635                 # perform the edit
636                 if cl.hasnode(nodeid):
637                     # edit existing
638                     cl.set(nodeid, **d)
639                 else:
640                     # new node
641                     found[cl.create(**d)] = 1
643             # retire the removed entries
644             for nodeid in cl.list():
645                 if not found.has_key(nodeid):
646                     cl.retire(nodeid)
648         w(_('''<p class="form-help">You may edit the contents of the
649         "%(classname)s" class using this form. Commas, newlines and double
650         quotes (") must be handled delicately. You may include commas and
651         newlines by enclosing the values in double-quotes ("). Double
652         quotes themselves must be quoted by doubling ("").</p>
653         <p class="form-help">Multilink properties have their multiple
654         values colon (":") separated (... ,"one:two:three", ...)</p>
655         <p class="form-help">Remove entries by deleting their line. Add
656         new entries by appending
657         them to the table - put an X in the id column.</p>''')%{'classname':cn})
659         l = []
660         for name in props:
661             l.append(name)
662         w('<tt>')
663         w(', '.join(l) + '\n')
664         w('</tt>')
666         w('<form onSubmit="return submit_once()" method="POST">')
667         w('<textarea name="rows" cols=80 rows=15>')
668         p = csv.parser()
669         for nodeid in cl.list():
670             l = []
671             for name in props:
672                 value = cl.get(nodeid, name)
673                 if value is None:
674                     l.append('')
675                 elif isinstance(value, type([])):
676                     l.append(cgi.escape(':'.join(map(str, value))))
677                 else:
678                     l.append(cgi.escape(str(cl.get(nodeid, name))))
679             w(p.join(l) + '\n')
681         w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
683     def classhelp(self):
684         '''Display a table of class info
685         '''
686         w = self.write
687         cn = self.form['classname'].value
688         cl = self.db.classes[cn]
689         props = self.form['properties'].value.split(',')
690         if cl.labelprop(1) in props:
691             sort = [cl.labelprop(1)]
692         else:
693             sort = props[0]
695         w('<table border=1 cellspacing=0 cellpaddin=2>')
696         w('<tr>')
697         for name in props:
698             w('<th align=left>%s</th>'%name)
699         w('</tr>')
700         for nodeid in cl.filter(None, {}, sort, []):
701             w('<tr>')
702             for name in props:
703                 value = cgi.escape(str(cl.get(nodeid, name)))
704                 w('<td align="left" valign="top">%s</td>'%value)
705             w('</tr>')
706         w('</table>')
708     def shownode(self, message=None, num_re=re.compile('^\d+$')):
709         ''' display an item
710         '''
711         cn = self.classname
712         cl = self.db.classes[cn]
713         keys = self.form.keys()
714         fromremove = 0
715         if self.form.has_key(':multilink'):
716             # is the multilink there because we came from remove()?
717             if self.form.has_key(':target'):
718                 xtra = ''
719                 fromremove = 1
720                 message = _('%s removed' % self.index_arg(":target")[0])
721             else:
722                 link = self.form[':multilink'].value
723                 designator, linkprop = link.split(':')
724                 xtra = ' for <a href="%s">%s</a>' % (designator, designator)
725         else:
726             xtra = ''
727         
728         # possibly perform an edit
729         # don't try to set properties if the user has just logged in
730         if keys and not fromremove and not self.form.has_key('__login_name'):
731             try:
732                 userid = self.db.user.lookup(self.user)
733                 if not self.db.security.hasPermission('Edit', userid, cn):
734                     message = _('You do not have permission to edit %s' %cn)
735                 else:
736                     props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
737                     # make changes to the node
738                     props = self._changenode(props)
739                     # handle linked nodes 
740                     self._post_editnode(self.nodeid)
741                     # and some nice feedback for the user
742                     if props:
743                         message = _('%(changes)s edited ok')%{'changes':
744                             ', '.join(props.keys())}
745                     elif self.form.has_key('__note') and self.form['__note'].value:
746                         message = _('note added')
747                     elif (self.form.has_key('__file') and
748                             self.form['__file'].filename):
749                         message = _('file added')
750                     else:
751                         message = _('nothing changed')
752             except:
753                 self.db.rollback()
754                 s = StringIO.StringIO()
755                 traceback.print_exc(None, s)
756                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
758         # now the display
759         id = self.nodeid
760         if cl.getkey():
761             id = cl.get(id, cl.getkey())
762         self.pagehead('%s: %s %s'%(self.classname.capitalize(), id, xtra),
763             message)
765         nodeid = self.nodeid
767         # use the template to display the item
768         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
769             self.classname)
770         item.render(nodeid)
772         self.pagefoot()
773     showissue = shownode
774     showmsg = shownode
775     searchissue = searchnode
777     def showquery(self):
778         queries = self.db.getclass(self.classname)
779         if self.form.keys():
780             sort = self.index_sort()
781             group = self.index_arg(':group')
782             filter = self.index_arg(':filter')
783             columns = self.index_arg(':columns')
784             filterspec = self.index_filterspec(filter, queries.get(self.nodeid, 'klass'))
785             if self.form.has_key('search_text'):
786                 search_text = self.form['search_text'].value
787                 search_text = urllib.quote(search_text)
788             else:
789                 search_text = ''
790             if self.form.has_key(':pagesize'):
791                 pagesize = int(self.form[':pagesize'].value)
792             else:
793                 pagesize = 50
794             # format a query string
795             qd = {}
796             qd[':sort'] = ','.join(map(urllib.quote, sort))
797             qd[':group'] = ','.join(map(urllib.quote, group))
798             qd[':filter'] = ','.join(map(urllib.quote, filter))
799             qd[':columns'] = ','.join(map(urllib.quote, columns))
800             for k, l in filterspec.items():
801                 qd[urllib.quote(k)] = ','.join(map(urllib.quote, l))
802             url = '&'.join([k+'='+v for k,v in qd.items()])
803             url += '&:pagesize=%s' % pagesize
804             if search_text:
805                 url += '&search_text=%s' % search_text
806             if url != queries.get(self.nodeid, 'url'):
807                 queries.set(self.nodeid, url=url)
808                 message = _('url edited ok')
809             else:
810                 message = _('nothing changed')
811         else:
812             message = None
813         nm = queries.get(self.nodeid, 'name')
814         self.pagehead('%s: %s'%(self.classname.capitalize(), nm), message)
816         # use the template to display the item
817         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
818             self.classname)
819         item.render(self.nodeid)
820         self.pagefoot()
821         
822     def _changenode(self, props):
823         ''' change the node based on the contents of the form
824         '''
825         cl = self.db.classes[self.classname]
827         # create the message
828         message, files = self._handle_message()
829         if message:
830             props['messages'] = cl.get(self.nodeid, 'messages') + [message]
831         if files:
832             props['files'] = cl.get(self.nodeid, 'files') + files
834         # make the changes
835         return cl.set(self.nodeid, **props)
837     def _createnode(self):
838         ''' create a node based on the contents of the form
839         '''
840         cl = self.db.classes[self.classname]
841         props = parsePropsFromForm(self.db, cl, self.form)
843         # check for messages and files
844         message, files = self._handle_message()
845         if message:
846             props['messages'] = [message]
847         if files:
848             props['files'] = files
849         # create the node and return it's id
850         return cl.create(**props)
852     def _handle_message(self):
853         ''' generate an edit message
854         '''
855         # handle file attachments 
856         files = []
857         if self.form.has_key('__file'):
858             file = self.form['__file']
859             if file.filename:
860                 filename = file.filename.split('\\')[-1]
861                 mime_type = mimetypes.guess_type(filename)[0]
862                 if not mime_type:
863                     mime_type = "application/octet-stream"
864                 # create the new file entry
865                 files.append(self.db.file.create(type=mime_type,
866                     name=filename, content=file.file.read()))
868         # we don't want to do a message if none of the following is true...
869         cn = self.classname
870         cl = self.db.classes[self.classname]
871         props = cl.getprops()
872         note = None
873         # in a nutshell, don't do anything if there's no note or there's no
874         # NOSY
875         if self.form.has_key('__note'):
876             note = self.form['__note'].value.strip()
877         if not note:
878             return None, files
879         if not props.has_key('messages'):
880             return None, files
881         if not isinstance(props['messages'], hyperdb.Multilink):
882             return None, files
883         if not props['messages'].classname == 'msg':
884             return None, files
885         if not (self.form.has_key('nosy') or note):
886             return None, files
888         # handle the note
889         if '\n' in note:
890             summary = re.split(r'\n\r?', note)[0]
891         else:
892             summary = note
893         m = ['%s\n'%note]
895         # handle the messageid
896         # TODO: handle inreplyto
897         messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
898             self.classname, self.instance.MAIL_DOMAIN)
900         # now create the message, attaching the files
901         content = '\n'.join(m)
902         message_id = self.db.msg.create(author=self.getuid(),
903             recipients=[], date=date.Date('.'), summary=summary,
904             content=content, files=files, messageid=messageid)
906         # update the messages property
907         return message_id, files
909     def _post_editnode(self, nid):
910         '''Do the linking part of the node creation.
912            If a form element has :link or :multilink appended to it, its
913            value specifies a node designator and the property on that node
914            to add _this_ node to as a link or multilink.
916            This is typically used on, eg. the file upload page to indicated
917            which issue to link the file to.
919            TODO: I suspect that this and newfile will go away now that
920            there's the ability to upload a file using the issue __file form
921            element!
922         '''
923         cn = self.classname
924         cl = self.db.classes[cn]
925         # link if necessary
926         keys = self.form.keys()
927         for key in keys:
928             if key == ':multilink':
929                 value = self.form[key].value
930                 if type(value) != type([]): value = [value]
931                 for value in value:
932                     designator, property = value.split(':')
933                     link, nodeid = hyperdb.splitDesignator(designator)
934                     link = self.db.classes[link]
935                     # take a dupe of the list so we're not changing the cache
936                     value = link.get(nodeid, property)[:]
937                     value.append(nid)
938                     link.set(nodeid, **{property: value})
939             elif key == ':link':
940                 value = self.form[key].value
941                 if type(value) != type([]): value = [value]
942                 for value in value:
943                     designator, property = value.split(':')
944                     link, nodeid = hyperdb.splitDesignator(designator)
945                     link = self.db.classes[link]
946                     link.set(nodeid, **{property: nid})
948     def newnode(self, message=None):
949         ''' Add a new node to the database.
950         
951         The form works in two modes: blank form and submission (that is,
952         the submission goes to the same URL). **Eventually this means that
953         the form will have previously entered information in it if
954         submission fails.
956         The new node will be created with the properties specified in the
957         form submission. For multilinks, multiple form entries are handled,
958         as are prop=value,value,value. You can't mix them though.
960         If the new node is to be referenced from somewhere else immediately
961         (ie. the new node is a file that is to be attached to a support
962         issue) then supply one of these arguments in addition to the usual
963         form entries:
964             :link=designator:property
965             :multilink=designator:property
966         ... which means that once the new node is created, the "property"
967         on the node given by "designator" should now reference the new
968         node's id. The node id will be appended to the multilink.
969         '''
970         cn = self.classname
971         userid = self.db.user.lookup(self.user)
972         if not self.db.security.hasPermission('View', userid, cn):
973             raise Unauthorised, _("You do not have permission to access"\
974                         " %(action)s.")%{'action': self.classname}
975         cl = self.db.classes[cn]
976         if self.form.has_key(':multilink'):
977             link = self.form[':multilink'].value
978             designator, linkprop = link.split(':')
979             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
980         else:
981             xtra = ''
983         # possibly perform a create
984         keys = self.form.keys()
985         if [i for i in keys if i[0] != ':']:
986             # no dice if you can't edit!
987             if not self.db.security.hasPermission('Edit', userid, cn):
988                 raise Unauthorised, _("You do not have permission to access"\
989                             " %(action)s.")%{'action': 'new'+self.classname}
990             props = {}
991             try:
992                 nid = self._createnode()
993                 # handle linked nodes 
994                 self._post_editnode(nid)
995                 # and some nice feedback for the user
996                 message = _('%(classname)s created ok')%{'classname': cn}
998                 # render the newly created issue
999                 self.db.commit()
1000                 self.nodeid = nid
1001                 self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
1002                     message)
1003                 item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 
1004                     self.classname)
1005                 item.render(nid)
1006                 self.pagefoot()
1007                 return
1008             except:
1009                 self.db.rollback()
1010                 s = StringIO.StringIO()
1011                 traceback.print_exc(None, s)
1012                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1013         self.pagehead(_('New %(classname)s %(xtra)s')%{
1014                 'classname': self.classname.capitalize(),
1015                 'xtra': xtra }, message)
1017         # call the template
1018         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1019             self.classname)
1020         newitem.render(self.form)
1022         self.pagefoot()
1023     newissue = newnode
1025     def newuser(self, message=None):
1026         ''' Add a new user to the database.
1028             Don't do any of the message or file handling, just create the node.
1029         '''
1030         userid = self.db.user.lookup(self.user)
1031         if not self.db.security.hasPermission('Edit', userid, 'user'):
1032             raise Unauthorised, _("You do not have permission to access"\
1033                         " %(action)s.")%{'action': 'newuser'}
1035         cn = self.classname
1036         cl = self.db.classes[cn]
1038         # possibly perform a create
1039         keys = self.form.keys()
1040         if [i for i in keys if i[0] != ':']:
1041             try:
1042                 props = parsePropsFromForm(self.db, cl, self.form)
1043                 nid = cl.create(**props)
1044                 # handle linked nodes 
1045                 self._post_editnode(nid)
1046                 # and some nice feedback for the user
1047                 message = _('%(classname)s created ok')%{'classname': cn}
1048             except:
1049                 self.db.rollback()
1050                 s = StringIO.StringIO()
1051                 traceback.print_exc(None, s)
1052                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1053         self.pagehead(_('New %(classname)s')%{'classname':
1054              self.classname.capitalize()}, message)
1056         # call the template
1057         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1058             self.classname)
1059         newitem.render(self.form)
1061         self.pagefoot()
1063     def newfile(self, message=None):
1064         ''' Add a new file to the database.
1065         
1066         This form works very much the same way as newnode - it just has a
1067         file upload.
1068         '''
1069         userid = self.db.user.lookup(self.user)
1070         if not self.db.security.hasPermission('Edit', userid, 'file'):
1071             raise Unauthorised, _("You do not have permission to access"\
1072                         " %(action)s.")%{'action': 'newfile'}
1073         cn = self.classname
1074         cl = self.db.classes[cn]
1075         props = parsePropsFromForm(self.db, cl, self.form)
1076         if self.form.has_key(':multilink'):
1077             link = self.form[':multilink'].value
1078             designator, linkprop = link.split(':')
1079             xtra = ' for <a href="%s">%s</a>' % (designator, designator)
1080         else:
1081             xtra = ''
1083         # possibly perform a create
1084         keys = self.form.keys()
1085         if [i for i in keys if i[0] != ':']:
1086             try:
1087                 file = self.form['content']
1088                 mime_type = mimetypes.guess_type(file.filename)[0]
1089                 if not mime_type:
1090                     mime_type = "application/octet-stream"
1091                 # save the file
1092                 props['type'] = mime_type
1093                 props['name'] = file.filename
1094                 props['content'] = file.file.read()
1095                 nid = cl.create(**props)
1096                 # handle linked nodes
1097                 self._post_editnode(nid)
1098                 # and some nice feedback for the user
1099                 message = _('%(classname)s created ok')%{'classname': cn}
1100             except:
1101                 self.db.rollback()
1102                 s = StringIO.StringIO()
1103                 traceback.print_exc(None, s)
1104                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1106         self.pagehead(_('New %(classname)s %(xtra)s')%{
1107                 'classname': self.classname.capitalize(),
1108                 'xtra': xtra }, message)
1109         newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
1110             self.classname)
1111         newitem.render(self.form)
1112         self.pagefoot()
1114     def showuser(self, message=None, num_re=re.compile('^\d+$')):
1115         '''Display a user page for editing. Make sure the user is allowed
1116            to edit this node, and also check for password changes.
1118            Note: permission checks for this node are handled in the template.
1119         '''
1120         user = self.db.user
1122         # get the username of the node being edited
1123         try:
1124             node_user = user.get(self.nodeid, 'username')
1125         except IndexError:
1126             raise NotFound, 'user%s'%self.nodeid
1128         #
1129         # perform any editing
1130         #
1131         keys = self.form.keys()
1132         if keys:
1133             try:
1134                 props = parsePropsFromForm(self.db, user, self.form,
1135                     self.nodeid)
1136                 set_cookie = 0
1137                 if props.has_key('password'):
1138                     password = self.form['password'].value.strip()
1139                     if not password:
1140                         # no password was supplied - don't change it
1141                         del props['password']
1142                     elif self.nodeid == self.getuid():
1143                         # this is the logged-in user's password
1144                         set_cookie = password
1145                 user.set(self.nodeid, **props)
1146                 # and some feedback for the user
1147                 message = _('%(changes)s edited ok')%{'changes':
1148                     ', '.join(props.keys())}
1149             except:
1150                 self.db.rollback()
1151                 s = StringIO.StringIO()
1152                 traceback.print_exc(None, s)
1153                 message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
1154         else:
1155             set_cookie = 0
1157         # fix the cookie if the password has changed
1158         if set_cookie:
1159             self.set_cookie(self.user, set_cookie)
1161         #
1162         # now the display
1163         #
1164         self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
1166         # use the template to display the item
1167         item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
1168         item.render(self.nodeid)
1169         self.pagefoot()
1171     def showfile(self):
1172         ''' display a file
1173         '''
1174         # nothing in xtrapath - edit the file's metadata
1175         if self.xtrapath is None:
1176             return self.shownode()
1178         # something in xtrapath - download the file    
1179         nodeid = self.nodeid
1180         cl = self.db.classes[self.classname]
1181         try:
1182             mime_type = cl.get(nodeid, 'type')
1183         except IndexError:
1184             raise NotFound, 'file%s'%nodeid
1185         if mime_type == 'message/rfc822':
1186             mime_type = 'text/plain'
1187         self.header(headers={'Content-Type': mime_type})
1188         self.write(cl.get(nodeid, 'content'))
1189         
1190     def permission(self):
1191         '''
1192         '''
1194     def classes(self, message=None):
1195         ''' display a list of all the classes in the database
1196         '''
1197         userid = self.db.user.lookup(self.user)
1198         if not self.db.security.hasPermission('Edit', userid):
1199             raise Unauthorised, _("You do not have permission to access"\
1200                         " %(action)s.")%{'action': 'all classes'}
1202         self.pagehead(_('Table of classes'), message)
1203         classnames = self.db.classes.keys()
1204         classnames.sort()
1205         self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
1206         for cn in classnames:
1207             cl = self.db.getclass(cn)
1208             self.write('<tr class="list-header"><th colspan=2 align=left>'
1209                 '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
1210             for key, value in cl.properties.items():
1211                 if value is None: value = ''
1212                 else: value = str(value)
1213                 self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
1214                     key, cgi.escape(value)))
1215         self.write('</table>')
1216         self.pagefoot()
1218     def unauthorised(self, message):
1219         ''' The user is not authorised to do something. If they're
1220             anonymous, throw up a login box. If not, just tell them they
1221             can't do whatever it was they were trying to do.
1223             Bot cases print up the message, which is most likely the
1224             argument to the Unauthorised exception.
1225         '''
1226         self.header(response=403)
1227         if self.desired_action is None or self.desired_action == 'login':
1228             if not message:
1229                 message=_("You do not have permission.")
1230             action = 'index'
1231         else:
1232             if not message:
1233                 message=_("You do not have permission to access"\
1234                     " %(action)s.")%{'action': self.desired_action}
1235             action = self.desired_action
1236         if self.user == 'anonymous':
1237             self.login(action=action, message=message)
1238         else:
1239             self.pagehead(_('Not Authorised'))
1240             self.write('<p class="system-msg">%s</p>'%message)
1241             self.pagefoot()
1243     def login(self, message=None, newuser_form=None, action='index'):
1244         '''Display a login page.
1245         '''
1246         self.pagehead(_('Login to roundup'))
1247         if message:
1248             self.write('<p class="system-msg">%s</p>'%message)
1249         self.write(_('''
1250 <table>
1251 <tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
1252 <form onSubmit="return submit_once()" action="login_action" method=POST>
1253 <input type="hidden" name="__destination_url" value="%(action)s">
1254 <tr><td align=right>Login name: </td>
1255     <td><input name="__login_name"></td></tr>
1256 <tr><td align=right>Password: </td>
1257     <td><input type="password" name="__login_password"></td></tr>
1258 <tr><td></td>
1259     <td><input type="submit" value="Log In"></td></tr>
1260 </form>
1261 ''')%locals())
1262         userid = self.db.user.lookup(self.user)
1263         if not self.db.security.hasPermission('Web Registration', userid):
1264             self.write('</table>')
1265             self.pagefoot()
1266             return
1267         values = {'realname': '', 'organisation': '', 'address': '',
1268             'phone': '', 'username': '', 'password': '', 'confirm': '',
1269             'action': action, 'alternate_addresses': ''}
1270         if newuser_form is not None:
1271             for key in newuser_form.keys():
1272                 values[key] = newuser_form[key].value
1273         self.write(_('''
1274 <p>
1275 <tr><td colspan=2 class="strong-header">New User Registration</td></tr>
1276 <tr><td colspan=2><em>marked items</em> are optional...</td></tr>
1277 <form onSubmit="return submit_once()" action="newuser_action" method=POST>
1278 <input type="hidden" name="__destination_url" value="%(action)s">
1279 <tr><td align=right><em>Name: </em></td>
1280     <td><input name="realname" value="%(realname)s" size=40></td></tr>
1281 <tr><td align=right><em>Organisation: </em></td>
1282     <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
1283 <tr><td align=right>E-Mail Address: </td>
1284     <td><input name="address" value="%(address)s" size=40></td></tr>
1285 <tr><td align=right><em>Alternate E-mail Addresses: </em></td>
1286     <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
1287 <tr><td align=right><em>Phone: </em></td>
1288     <td><input name="phone" value="%(phone)s"></td></tr>
1289 <tr><td align=right>Preferred Login name: </td>
1290     <td><input name="username" value="%(username)s"></td></tr>
1291 <tr><td align=right>Password: </td>
1292     <td><input type="password" name="password" value="%(password)s"></td></tr>
1293 <tr><td align=right>Password Again: </td>
1294     <td><input type="password" name="confirm" value="%(confirm)s"></td></tr>
1295 <tr><td></td>
1296     <td><input type="submit" value="Register"></td></tr>
1297 </form>
1298 </table>
1299 ''')%values)
1300         self.pagefoot()
1302     def login_action(self, message=None):
1303         '''Attempt to log a user in and set the cookie
1305         returns 0 if a page is generated as a result of this call, and
1306         1 if not (ie. the login is successful
1307         '''
1308         if not self.form.has_key('__login_name'):
1309             self.login(message=_('Username required'))
1310             return 0
1311         self.user = self.form['__login_name'].value
1312         # re-open the database for real, using the user
1313         self.opendb(self.user)
1314         if self.form.has_key('__login_password'):
1315             password = self.form['__login_password'].value
1316         else:
1317             password = ''
1318         # make sure the user exists
1319         try:
1320             uid = self.db.user.lookup(self.user)
1321         except KeyError:
1322             name = self.user
1323             self.make_user_anonymous()
1324             action = self.form['__destination_url'].value
1325             self.login(message=_('No such user "%(name)s"')%locals(),
1326                 action=action)
1327             return 0
1329         # and that the password is correct
1330         pw = self.db.user.get(uid, 'password')
1331         if password != pw:
1332             self.make_user_anonymous()
1333             action = self.form['__destination_url'].value
1334             self.login(message=_('Incorrect password'), action=action)
1335             return 0
1337         self.set_cookie(self.user, password)
1338         return 1
1340     def newuser_action(self, message=None):
1341         '''Attempt to create a new user based on the contents of the form
1342         and then set the cookie.
1344         return 1 on successful login
1345         '''
1346         # make sure we're allowed to register
1347         userid = self.db.user.lookup(self.user)
1348         if not self.db.security.hasPermission('Web Registration', userid):
1349             raise Unauthorised, _("You do not have permission to access"\
1350                         " %(action)s.")%{'action': 'registration'}
1352         # re-open the database as "admin"
1353         if self.user != 'admin':
1354             self.opendb('admin')
1355             
1356         # create the new user
1357         cl = self.db.user
1358         try:
1359             props = parsePropsFromForm(self.db, cl, self.form)
1360             props['roles'] = self.instance.NEW_WEB_USER_ROLES
1361             uid = cl.create(**props)
1362             self.db.commit()
1363         except ValueError, message:
1364             action = self.form['__destination_url'].value
1365             self.login(message, action=action)
1366             return 0
1368         # log the new user in
1369         self.user = cl.get(uid, 'username')
1370         # re-open the database for real, using the user
1371         self.opendb(self.user)
1372         password = cl.get(uid, 'password')
1373         self.set_cookie(self.user, password)
1374         return 1
1376     def set_cookie(self, user, password):
1377         # TODO generate a much, much stronger session key ;)
1378         self.session = binascii.b2a_base64(repr(time.time())).strip()
1380         # clean up the base64
1381         if self.session[-1] == '=':
1382             if self.session[-2] == '=':
1383                 self.session = self.session[:-2]
1384             else:
1385                 self.session = self.session[:-1]
1387         # insert the session in the sessiondb
1388         self.db.sessions.set(self.session, user=user, last_use=time.time())
1390         # and commit immediately
1391         self.db.sessions.commit()
1393         # expire us in a long, long time
1394         expire = Cookie._getdate(86400*365)
1396         # generate the cookie path - make sure it has a trailing '/'
1397         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1398             ''))
1399         self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
1400             self.session, expire, path)})
1402     def make_user_anonymous(self):
1403         ''' Make us anonymous
1405             This method used to handle non-existence of the 'anonymous'
1406             user, but that user is mandatory now.
1407         '''
1408         self.db.user.lookup('anonymous')
1409         self.user = 'anonymous'
1411     def logout(self, message=None):
1412         ''' Make us really anonymous - nuke the cookie too
1413         '''
1414         self.make_user_anonymous()
1416         # construct the logout cookie
1417         now = Cookie._getdate()
1418         path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
1419             ''))
1420         self.header({'Set-Cookie':
1421             'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
1422             path)})
1423         self.login()
1425     def opendb(self, user):
1426         ''' Open the database - but include the definition of the sessions db.
1427         '''
1428         # open the db if the user has changed
1429         if not hasattr(self, 'db') or user != self.db.journaltag:
1430             self.db = self.instance.open(user)
1432     def main(self):
1433         ''' Wrap the request and handle unauthorised requests
1434         '''
1435         self.desired_action = None
1436         try:
1437             self.main_action()
1438         except Unauthorised, message:
1439             self.unauthorised(message)
1441     def main_action(self):
1442         '''Wrap the database accesses so we can close the database cleanly
1443         '''
1444         # determine the uid to use
1445         self.opendb('admin')
1447         # make sure we have the session Class
1448         sessions = self.db.sessions
1450         # age sessions, remove when they haven't been used for a week
1451         # TODO: this shouldn't be done every access
1452         week = 60*60*24*7
1453         now = time.time()
1454         for sessid in sessions.list():
1455             interval = now - sessions.get(sessid, 'last_use')
1456             if interval > week:
1457                 sessions.destroy(sessid)
1459         # look up the user session cookie
1460         cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
1461         user = 'anonymous'
1463         if (cookie.has_key('roundup_user') and
1464                 cookie['roundup_user'].value != 'deleted'):
1466             # get the session key from the cookie
1467             self.session = cookie['roundup_user'].value
1468             # get the user from the session
1469             try:
1470                 # update the lifetime datestamp
1471                 sessions.set(self.session, last_use=time.time())
1472                 sessions.commit()
1473                 user = sessions.get(self.session, 'user')
1474             except KeyError:
1475                 user = 'anonymous'
1477         # sanity check on the user still being valid
1478         try:
1479             self.db.user.lookup(user)
1480         except (KeyError, TypeError):
1481             user = 'anonymous'
1483         # make sure the anonymous user is valid if we're using it
1484         if user == 'anonymous':
1485             self.make_user_anonymous()
1486         else:
1487             self.user = user
1489         # now figure which function to call
1490         path = self.split_path
1491         self.xtrapath = None
1493         # default action to index if the path has no information in it
1494         if not path or path[0] in ('', 'index'):
1495             action = 'index'
1496         else:
1497             action = path[0]
1498             if len(path) > 1:
1499                 self.xtrapath = path[1:]
1500         self.desired_action = action
1502         # everyone is allowed to try to log in
1503         if action == 'login_action':
1504             # try to login
1505             if not self.login_action():
1506                 return
1507             # figure the resulting page
1508             action = self.form['__destination_url'].value
1510         # allow anonymous people to register
1511         elif action == 'newuser_action':
1512             # try to add the user
1513             if not self.newuser_action():
1514                 return
1515             # figure the resulting page
1516             action = self.form['__destination_url'].value
1518         # ok, now we have figured out who the user is, make sure the user
1519         # has permission to use this interface
1520         userid = self.db.user.lookup(self.user)
1521         if not self.db.security.hasPermission('Web Access', userid):
1522             raise Unauthorised, \
1523                 _("You do not have permission to access this interface.")
1525         # re-open the database for real, using the user
1526         self.opendb(self.user)
1528         # make sure we have a sane action
1529         if not action:
1530             action = 'index'
1532         # just a regular action
1533         try:
1534             self.do_action(action)
1535         except Unauthorised, message:
1536             # if unauth is raised here, then a page header will have 
1537             # been displayed
1538             self.write('<p class="system-msg">%s</p>'%message)
1539         else:
1540             # commit all changes to the database
1541             self.db.commit()
1543     def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
1544             nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
1545         '''Figure the user's action and do it.
1546         '''
1547         # here be the "normal" functionality
1548         if action == 'index':
1549             self.index()
1550             return
1551         if action == 'list_classes':
1552             self.classes()
1553             return
1554         if action == 'classhelp':
1555             self.classhelp()
1556             return
1557         if action == 'login':
1558             self.login()
1559             return
1560         if action == 'logout':
1561             self.logout()
1562             return
1563         if action == 'remove':
1564             self.remove()
1565             return
1567         # see if we're to display an existing node
1568         m = dre.match(action)
1569         if m:
1570             self.classname = m.group(1)
1571             self.nodeid = m.group(2)
1572             try:
1573                 cl = self.db.classes[self.classname]
1574             except KeyError:
1575                 raise NotFound, self.classname
1576             try:
1577                 cl.get(self.nodeid, 'id')
1578             except IndexError:
1579                 raise NotFound, self.nodeid
1580             try:
1581                 func = getattr(self, 'show%s'%self.classname)
1582             except AttributeError:
1583                 raise NotFound, 'show%s'%self.classname
1584             func()
1585             return
1587         # see if we're to put up the new node page
1588         m = nre.match(action)
1589         if m:
1590             self.classname = m.group(1)
1591             try:
1592                 func = getattr(self, 'new%s'%self.classname)
1593             except AttributeError:
1594                 raise NotFound, 'new%s'%self.classname
1595             func()
1596             return
1598         # see if we're to put up the new node page
1599         m = sre.match(action)
1600         if m:
1601             self.classname = m.group(1)
1602             try:
1603                 func = getattr(self, 'search%s'%self.classname)
1604             except AttributeError:
1605                 raise NotFound
1606             func()
1607             return
1609         # otherwise, display the named class
1610         self.classname = action
1611         try:
1612             self.db.getclass(self.classname)
1613         except KeyError:
1614             raise NotFound, self.classname
1615         self.list()
1617     def remove(self,  dre=re.compile(r'([^\d]+)(\d+)')):
1618         target = self.index_arg(':target')[0]
1619         m = dre.match(target)
1620         if m:
1621             classname = m.group(1)
1622             nodeid = m.group(2)
1623             cl = self.db.getclass(classname)
1624             cl.retire(nodeid)
1625             # now take care of the reference
1626             parentref =  self.index_arg(':multilink')[0]
1627             parent, prop = parentref.split(':')
1628             m = dre.match(parent)
1629             if m:
1630                 self.classname = m.group(1)
1631                 self.nodeid = m.group(2)
1632                 cl = self.db.getclass(self.classname)
1633                 value = cl.get(self.nodeid, prop)
1634                 value.remove(nodeid)
1635                 cl.set(self.nodeid, **{prop:value})
1636                 func = getattr(self, 'show%s'%self.classname)
1637                 return func()
1638             else:
1639                 raise NotFound, parent
1640         else:
1641             raise NotFound, target
1643 class ExtendedClient(Client): 
1644     '''Includes pages and page heading information that relate to the
1645        extended schema.
1646     ''' 
1647     showsupport = Client.shownode
1648     showtimelog = Client.shownode
1649     newsupport = Client.newnode
1650     newtimelog = Client.newnode
1651     searchsupport = Client.searchnode
1653     default_index_sort = ['-activity']
1654     default_index_group = ['priority']
1655     default_index_filter = ['status']
1656     default_index_columns = ['activity','status','title','assignedto']
1657     default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
1658     default_pagesize = '50'
1660 def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
1661     '''Pull properties for the given class out of the form.
1662     '''
1663     props = {}
1664     keys = form.keys()
1665     for key in keys:
1666         if not cl.properties.has_key(key):
1667             continue
1668         proptype = cl.properties[key]
1669         if isinstance(proptype, hyperdb.String):
1670             value = form[key].value.strip()
1671         elif isinstance(proptype, hyperdb.Password):
1672             value = password.Password(form[key].value.strip())
1673         elif isinstance(proptype, hyperdb.Date):
1674             value = form[key].value.strip()
1675             if value:
1676                 value = date.Date(form[key].value.strip())
1677             else:
1678                 value = None
1679         elif isinstance(proptype, hyperdb.Interval):
1680             value = form[key].value.strip()
1681             if value:
1682                 value = date.Interval(form[key].value.strip())
1683             else:
1684                 value = None
1685         elif isinstance(proptype, hyperdb.Link):
1686             value = form[key].value.strip()
1687             # see if it's the "no selection" choice
1688             if value == '-1':
1689                 value = None
1690             else:
1691                 # handle key values
1692                 link = cl.properties[key].classname
1693                 if not num_re.match(value):
1694                     try:
1695                         value = db.classes[link].lookup(value)
1696                     except KeyError:
1697                         raise ValueError, _('property "%(propname)s": '
1698                             '%(value)s not a %(classname)s')%{'propname':key, 
1699                             'value': value, 'classname': link}
1700         elif isinstance(proptype, hyperdb.Multilink):
1701             value = form[key]
1702             if hasattr(value, 'value'):
1703                 # Quite likely to be a FormItem instance
1704                 value = value.value
1705             if not isinstance(value, type([])):
1706                 value = [i.strip() for i in value.split(',')]
1707             else:
1708                 value = [i.strip() for i in value]
1709             link = cl.properties[key].classname
1710             l = []
1711             for entry in map(str, value):
1712                 if entry == '': continue
1713                 if not num_re.match(entry):
1714                     try:
1715                         entry = db.classes[link].lookup(entry)
1716                     except KeyError:
1717                         raise ValueError, _('property "%(propname)s": '
1718                             '"%(value)s" not an entry of %(classname)s')%{
1719                             'propname':key, 'value': entry, 'classname': link}
1720                 l.append(entry)
1721             l.sort()
1722             value = l
1723         elif isinstance(proptype, hyperdb.Boolean):
1724             value = form[key].value.strip()
1725             props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
1726         elif isinstance(proptype, hyperdb.Number):
1727             value = form[key].value.strip()
1728             props[key] = value = int(value)
1730         # get the old value
1731         if nodeid:
1732             try:
1733                 existing = cl.get(nodeid, key)
1734             except KeyError:
1735                 # this might be a new property for which there is no existing
1736                 # value
1737                 if not cl.properties.has_key(key): raise
1739             # if changed, set it
1740             if value != existing:
1741                 props[key] = value
1742         else:
1743             props[key] = value
1744     return props
1747 # $Log: not supported by cvs2svn $
1748 # Revision 1.157  2002/08/13 20:16:09  gmcm
1749 # Use a real parser for templates.
1750 # Rewrite htmltemplate to use the parser (hack, hack).
1751 # Move the "do_XXX" methods to template_funcs.py.
1752 # Redo the funcion tests (but not Template tests - they're hopeless).
1753 # Simplified query form in cgi_client.
1754 # Ability to delete msgs, files, queries.
1755 # Ability to edit the metadata on files.
1757 # Revision 1.156  2002/08/01 15:06:06  gmcm
1758 # Use same regex to split search terms as used to index text.
1759 # Fix to back_metakit for not changing journaltag on reopen.
1760 # Fix htmltemplate's do_link so [No <whatever>] strings are href'd.
1761 # Fix bogus "nosy edited ok" msg - the **d syntax does NOT share d between caller and callee.
1763 # Revision 1.155  2002/08/01 00:56:22  richard
1764 # Added the web access and email access permissions, so people can restrict
1765 # access to users who register through the email interface (for example).
1766 # Also added "security" command to the roundup-admin interface to display the
1767 # Role/Permission config for an instance.
1769 # Revision 1.154  2002/07/31 23:57:36  richard
1770 #  . web forms may now unset Link values (like assignedto)
1772 # Revision 1.153  2002/07/31 22:40:50  gmcm
1773 # Fixes to the search form and saving queries.
1774 # Fixes to  sorting in back_metakit.py.
1776 # Revision 1.152  2002/07/31 22:04:14  richard
1777 # cleanup
1779 # Revision 1.151  2002/07/30 21:37:43  richard
1780 # oops, thanks Duncan Booth for spotting this one
1782 # Revision 1.150  2002/07/30 20:43:18  gmcm
1783 # Oops, fix the permission check!
1785 # Revision 1.149  2002/07/30 20:04:38  gmcm
1786 # Adapt metakit backend to new security scheme.
1787 # Put some more permission checks in cgi_client.
1789 # Revision 1.148  2002/07/30 16:09:11  gmcm
1790 # Simple optimization.
1792 # Revision 1.147  2002/07/30 08:22:38  richard
1793 # Session storage in the hyperdb was horribly, horribly inefficient. We use
1794 # a simple anydbm wrapper now - which could be overridden by the metakit
1795 # backend or RDB backend if necessary.
1796 # Much, much better.
1798 # Revision 1.146  2002/07/30 05:27:30  richard
1799 # nicer error messages, and a bugfix
1801 # Revision 1.145  2002/07/26 08:26:59  richard
1802 # Very close now. The cgi and mailgw now use the new security API. The two
1803 # templates have been migrated to that setup. Lots of unit tests. Still some
1804 # issue in the web form for editing Roles assigned to users.
1806 # Revision 1.144  2002/07/25 07:14:05  richard
1807 # Bugger it. Here's the current shape of the new security implementation.
1808 # Still to do:
1809 #  . call the security funcs from cgi and mailgw
1810 #  . change shipped templates to include correct initialisation and remove
1811 #    the old config vars
1812 # ... that seems like a lot. The bulk of the work has been done though. Honest :)
1814 # Revision 1.143  2002/07/20 19:29:10  gmcm
1815 # Fixes/improvements to the search form & saved queries.
1817 # Revision 1.142  2002/07/18 11:17:30  gmcm
1818 # Add Number and Boolean types to hyperdb.
1819 # Add conversion cases to web, mail & admin interfaces.
1820 # Add storage/serialization cases to back_anydbm & back_metakit.
1822 # Revision 1.141  2002/07/17 12:39:10  gmcm
1823 # Saving, running & editing queries.
1825 # Revision 1.140  2002/07/14 23:17:15  richard
1826 # cleaned up structure
1828 # Revision 1.139  2002/07/14 06:14:40  richard
1829 # Some more TODOs
1831 # Revision 1.138  2002/07/14 04:03:13  richard
1832 # Implemented a switch to disable journalling for a Class. CGI session
1833 # database now uses it.
1835 # Revision 1.137  2002/07/10 07:00:30  richard
1836 # removed debugging
1838 # Revision 1.136  2002/07/10 06:51:08  richard
1839 # . #576241 ] MultiLink problems in parsePropsFromForm
1841 # Revision 1.135  2002/07/10 00:22:34  richard
1842 #  . switched to using a session-based web login
1844 # Revision 1.134  2002/07/09 04:19:09  richard
1845 # Added reindex command to roundup-admin.
1846 # Fixed reindex on first access.
1847 # Also fixed reindexing of entries that change.
1849 # Revision 1.133  2002/07/08 15:32:05  gmcm
1850 # Pagination of index pages.
1851 # New search form.
1853 # Revision 1.132  2002/07/08 07:26:14  richard
1854 # ehem
1856 # Revision 1.131  2002/07/08 06:53:57  richard
1857 # Not sure why the cgi_client had an indexer argument.
1859 # Revision 1.130  2002/06/27 12:01:53  gmcm
1860 # If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
1861 # Some minor optimizations (only compile regexes once).
1863 # Revision 1.129  2002/06/20 23:52:11  richard
1864 # Better handling of unauth attempt to edit stuff
1866 # Revision 1.128  2002/06/12 21:28:25  gmcm
1867 # Allow form to set user-properties on a Fileclass.
1868 # Don't assume that a Fileclass is named "files".
1870 # Revision 1.127  2002/06/11 06:38:24  richard
1871 #  . #565996 ] The "Attach a File to this Issue" fails
1873 # Revision 1.126  2002/05/29 01:16:17  richard
1874 # Sorry about this huge checkin! It's fixing a lot of related stuff in one go
1875 # though.
1877 # . #541941 ] changing multilink properties by mail
1878 # . #526730 ] search for messages capability
1879 # . #505180 ] split MailGW.handle_Message
1880 #   - also changed cgi client since it was duplicating the functionality
1881 # . build htmlbase if tests are run using CVS checkout (removed note from
1882 #   installation.txt)
1883 # . don't create an empty message on email issue creation if the email is empty
1885 # Revision 1.125  2002/05/25 07:16:24  rochecompaan
1886 # Merged search_indexing-branch with HEAD
1888 # Revision 1.124  2002/05/24 02:09:24  richard
1889 # Nothing like a live demo to show up the bugs ;)
1891 # Revision 1.123  2002/05/22 05:04:13  richard
1892 # Oops
1894 # Revision 1.122  2002/05/22 04:12:05  richard
1895 #  . applied patch #558876 ] cgi client customization
1896 #    ... with significant additions and modifications ;)
1897 #    - extended handling of ML assignedto to all places it's handled
1898 #    - added more NotFound info
1900 # Revision 1.121  2002/05/21 06:08:10  richard
1901 # Handle migration
1903 # Revision 1.120  2002/05/21 06:05:53  richard
1904 #  . #551483 ] assignedto in Client.make_index_link
1906 # Revision 1.119  2002/05/15 06:21:21  richard
1907 #  . node caching now works, and gives a small boost in performance
1909 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1910 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1911 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1912 # (using if __debug__ which is compiled out with -O)
1914 # Revision 1.118  2002/05/12 23:46:33  richard
1915 # ehem, part 2
1917 # Revision 1.117  2002/05/12 23:42:29  richard
1918 # ehem
1920 # Revision 1.116  2002/05/02 08:07:49  richard
1921 # Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
1923 # Revision 1.115  2002/04/02 01:56:10  richard
1924 #  . stop sending blank (whitespace-only) notes
1926 # Revision 1.114.2.4  2002/05/02 11:49:18  rochecompaan
1927 # Allow customization of the search filters that should be displayed
1928 # on the search page.
1930 # Revision 1.114.2.3  2002/04/20 13:23:31  rochecompaan
1931 # We now have a separate search page for nodes.  Search links for
1932 # different classes can be customized in instance_config similar to
1933 # index links.
1935 # Revision 1.114.2.2  2002/04/19 19:54:42  rochecompaan
1936 # cgi_client.py
1937 #     removed search link for the time being
1938 #     moved rendering of matches to htmltemplate
1939 # hyperdb.py
1940 #     filtering of nodes on full text search incorporated in filter method
1941 # roundupdb.py
1942 #     added paramater to call of filter method
1943 # roundup_indexer.py
1944 #     added search method to RoundupIndexer class
1946 # Revision 1.114.2.1  2002/04/03 11:55:57  rochecompaan
1947 #  . Added feature #526730 - search for messages capability
1949 # Revision 1.114  2002/03/17 23:06:05  richard
1950 # oops
1952 # Revision 1.113  2002/03/14 23:59:24  richard
1953 #  . #517734 ] web header customisation is obscure
1955 # Revision 1.112  2002/03/12 22:52:26  richard
1956 # more pychecker warnings removed
1958 # Revision 1.111  2002/02/25 04:32:21  richard
1959 # ahem
1961 # Revision 1.110  2002/02/21 07:19:08  richard
1962 # ... and label, width and height control for extra flavour!
1964 # Revision 1.109  2002/02/21 07:08:19  richard
1965 # oops
1967 # Revision 1.108  2002/02/21 07:02:54  richard
1968 # The correct var is "HTTP_HOST"
1970 # Revision 1.107  2002/02/21 06:57:38  richard
1971 #  . Added popup help for classes using the classhelp html template function.
1972 #    - add <display call="classhelp('priority', 'id,name,description')">
1973 #      to an item page, and it generates a link to a popup window which displays
1974 #      the id, name and description for the priority class. The description
1975 #      field won't exist in most installations, but it will be added to the
1976 #      default templates.
1978 # Revision 1.106  2002/02/21 06:23:00  richard
1979 # *** empty log message ***
1981 # Revision 1.105  2002/02/20 05:52:10  richard
1982 # better error handling
1984 # Revision 1.104  2002/02/20 05:45:17  richard
1985 # Use the csv module for generating the form entry so it's correct.
1986 # [also noted the sf.net feature request id in the change log]
1988 # Revision 1.103  2002/02/20 05:05:28  richard
1989 #  . Added simple editing for classes that don't define a templated interface.
1990 #    - access using the admin "class list" interface
1991 #    - limited to admin-only
1992 #    - requires the csv module from object-craft (url given if it's missing)
1994 # Revision 1.102  2002/02/15 07:08:44  richard
1995 #  . Alternate email addresses are now available for users. See the MIGRATION
1996 #    file for info on how to activate the feature.
1998 # Revision 1.101  2002/02/14 23:39:18  richard
1999 # . All forms now have "double-submit" protection when Javascript is enabled
2000 #   on the client-side.
2002 # Revision 1.100  2002/01/16 07:02:57  richard
2003 #  . lots of date/interval related changes:
2004 #    - more relaxed date format for input
2006 # Revision 1.99  2002/01/16 03:02:42  richard
2007 # #503793 ] changing assignedto resets nosy list
2009 # Revision 1.98  2002/01/14 02:20:14  richard
2010 #  . changed all config accesses so they access either the instance or the
2011 #    config attriubute on the db. This means that all config is obtained from
2012 #    instance_config instead of the mish-mash of classes. This will make
2013 #    switching to a ConfigParser setup easier too, I hope.
2015 # At a minimum, this makes migration a _little_ easier (a lot easier in the
2016 # 0.5.0 switch, I hope!)
2018 # Revision 1.97  2002/01/11 23:22:29  richard
2019 #  . #502437 ] rogue reactor and unittest
2020 #    in short, the nosy reactor was modifying the nosy list. That code had
2021 #    been there for a long time, and I suspsect it was there because we
2022 #    weren't generating the nosy list correctly in other places of the code.
2023 #    We're now doing that, so the nosy-modifying code can go away from the
2024 #    nosy reactor.
2026 # Revision 1.96  2002/01/10 05:26:10  richard
2027 # missed a parsePropsFromForm in last update
2029 # Revision 1.95  2002/01/10 03:39:45  richard
2030 #  . fixed some problems with web editing and change detection
2032 # Revision 1.94  2002/01/09 13:54:21  grubert
2033 # _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
2035 # Revision 1.93  2002/01/08 11:57:12  richard
2036 # crying out for real configuration handling... :(
2038 # Revision 1.92  2002/01/08 04:12:05  richard
2039 # Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
2041 # Revision 1.91  2002/01/08 04:03:47  richard
2042 # I mucked the intent of the code up.
2044 # Revision 1.90  2002/01/08 03:56:55  richard
2045 # Oops, missed this before the beta:
2046 #  . #495392 ] empty nosy -patch
2048 # Revision 1.89  2002/01/07 20:24:45  richard
2049 # *mutter* stupid cutnpaste
2051 # Revision 1.88  2002/01/02 02:31:38  richard
2052 # Sorry for the huge checkin message - I was only intending to implement #496356
2053 # but I found a number of places where things had been broken by transactions:
2054 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
2055 #    for _all_ roundup-generated smtp messages to be sent to.
2056 #  . the transaction cache had broken the roundupdb.Class set() reactors
2057 #  . newly-created author users in the mailgw weren't being committed to the db
2059 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
2060 # on when I found that stuff :):
2061 #  . #496356 ] Use threading in messages
2062 #  . detectors were being registered multiple times
2063 #  . added tests for mailgw
2064 #  . much better attaching of erroneous messages in the mail gateway
2066 # Revision 1.87  2001/12/23 23:18:49  richard
2067 # We already had an admin-specific section of the web heading, no need to add
2068 # another one :)
2070 # Revision 1.86  2001/12/20 15:43:01  rochecompaan
2071 # Features added:
2072 #  .  Multilink properties are now displayed as comma separated values in
2073 #     a textbox
2074 #  .  The add user link is now only visible to the admin user
2075 #  .  Modified the mail gateway to reject submissions from unknown
2076 #     addresses if ANONYMOUS_ACCESS is denied
2078 # Revision 1.85  2001/12/20 06:13:24  rochecompaan
2079 # Bugs fixed:
2080 #   . Exception handling in hyperdb for strings-that-look-like numbers got
2081 #     lost somewhere
2082 #   . Internet Explorer submits full path for filename - we now strip away
2083 #     the path
2084 # Features added:
2085 #   . Link and multilink properties are now displayed sorted in the cgi
2086 #     interface
2088 # Revision 1.84  2001/12/18 15:30:30  rochecompaan
2089 # Fixed bugs:
2090 #  .  Fixed file creation and retrieval in same transaction in anydbm
2091 #     backend
2092 #  .  Cgi interface now renders new issue after issue creation
2093 #  .  Could not set issue status to resolved through cgi interface
2094 #  .  Mail gateway was changing status back to 'chatting' if status was
2095 #     omitted as an argument
2097 # Revision 1.83  2001/12/15 23:51:01  richard
2098 # Tested the changes and fixed a few problems:
2099 #  . files are now attached to the issue as well as the message
2100 #  . newuser is a real method now since we don't want to do the message/file
2101 #    stuff for it
2102 #  . added some documentation
2103 # The really big changes in the diff are a result of me moving some code
2104 # around to keep like methods together a bit better.
2106 # Revision 1.82  2001/12/15 19:24:39  rochecompaan
2107 #  . Modified cgi interface to change properties only once all changes are
2108 #    collected, files created and messages generated.
2109 #  . Moved generation of change note to nosyreactors.
2110 #  . We now check for changes to "assignedto" to ensure it's added to the
2111 #    nosy list.
2113 # Revision 1.81  2001/12/12 23:55:00  richard
2114 # Fixed some problems with user editing
2116 # Revision 1.80  2001/12/12 23:27:14  richard
2117 # Added a Zope frontend for roundup.
2119 # Revision 1.79  2001/12/10 22:20:01  richard
2120 # Enabled transaction support in the bsddb backend. It uses the anydbm code
2121 # where possible, only replacing methods where the db is opened (it uses the
2122 # btree opener specifically.)
2123 # Also cleaned up some change note generation.
2124 # Made the backends package work with pydoc too.
2126 # Revision 1.78  2001/12/07 05:59:27  rochecompaan
2127 # Fixed small bug that prevented adding issues through the web.
2129 # Revision 1.77  2001/12/06 22:48:29  richard
2130 # files multilink was being nuked in post_edit_node
2132 # Revision 1.76  2001/12/05 14:26:44  rochecompaan
2133 # Removed generation of change note from "sendmessage" in roundupdb.py.
2134 # The change note is now generated when the message is created.
2136 # Revision 1.75  2001/12/04 01:25:08  richard
2137 # Added some rollbacks where we were catching exceptions that would otherwise
2138 # have stopped committing.
2140 # Revision 1.74  2001/12/02 05:06:16  richard
2141 # . We now use weakrefs in the Classes to keep the database reference, so
2142 #   the close() method on the database is no longer needed.
2143 #   I bumped the minimum python requirement up to 2.1 accordingly.
2144 # . #487480 ] roundup-server
2145 # . #487476 ] INSTALL.txt
2147 # I also cleaned up the change message / post-edit stuff in the cgi client.
2148 # There's now a clearly marked "TODO: append the change note" where I believe
2149 # the change note should be added there. The "changes" list will obviously
2150 # have to be modified to be a dict of the changes, or somesuch.
2152 # More testing needed.
2154 # Revision 1.73  2001/12/01 07:17:50  richard
2155 # . We now have basic transaction support! Information is only written to
2156 #   the database when the commit() method is called. Only the anydbm
2157 #   backend is modified in this way - neither of the bsddb backends have been.
2158 #   The mail, admin and cgi interfaces all use commit (except the admin tool
2159 #   doesn't have a commit command, so interactive users can't commit...)
2160 # . Fixed login/registration forwarding the user to the right page (or not,
2161 #   on a failure)
2163 # Revision 1.72  2001/11/30 20:47:58  rochecompaan
2164 # Links in page header are now consistent with default sort order.
2166 # Fixed bugs:
2167 #     - When login failed the list of issues were still rendered.
2168 #     - User was redirected to index page and not to his destination url
2169 #       if his first login attempt failed.
2171 # Revision 1.71  2001/11/30 20:28:10  rochecompaan
2172 # Property changes are now completely traceable, whether changes are
2173 # made through the web or by email
2175 # Revision 1.70  2001/11/30 00:06:29  richard
2176 # Converted roundup/cgi_client.py to use _()
2177 # Added the status file, I18N_PROGRESS.txt
2179 # Revision 1.69  2001/11/29 23:19:51  richard
2180 # Removed the "This issue has been edited through the web" when a valid
2181 # change note is supplied.
2183 # Revision 1.68  2001/11/29 04:57:23  richard
2184 # a little comment
2186 # Revision 1.67  2001/11/28 21:55:35  richard
2187 #  . login_action and newuser_action return values were being ignored
2188 #  . Woohoo! Found that bloody re-login bug that was killing the mail
2189 #    gateway.
2190 #  (also a minor cleanup in hyperdb)
2192 # Revision 1.66  2001/11/27 03:00:50  richard
2193 # couple of bugfixes from latest patch integration
2195 # Revision 1.65  2001/11/26 23:00:53  richard
2196 # This config stuff is getting to be a real mess...
2198 # Revision 1.64  2001/11/26 22:56:35  richard
2199 # typo
2201 # Revision 1.63  2001/11/26 22:55:56  richard
2202 # Feature:
2203 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
2204 #    the instance.
2205 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
2206 #    signature info in e-mails.
2207 #  . Some more flexibility in the mail gateway and more error handling.
2208 #  . Login now takes you to the page you back to the were denied access to.
2210 # Fixed:
2211 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
2213 # Revision 1.62  2001/11/24 00:45:42  jhermann
2214 # typeof() instead of type(): avoid clash with database field(?) "type"
2216 # Fixes this traceback:
2218 # Traceback (most recent call last):
2219 #   File "roundup\cgi_client.py", line 535, in newnode
2220 #     self._post_editnode(nid)
2221 #   File "roundup\cgi_client.py", line 415, in _post_editnode
2222 #     if type(value) != type([]): value = [value]
2223 # UnboundLocalError: local variable 'type' referenced before assignment
2225 # Revision 1.61  2001/11/22 15:46:42  jhermann
2226 # Added module docstrings to all modules.
2228 # Revision 1.60  2001/11/21 22:57:28  jhermann
2229 # Added dummy hooks for I18N and some preliminary (test) markup of
2230 # translatable messages
2232 # Revision 1.59  2001/11/21 03:21:13  richard
2233 # oops
2235 # Revision 1.58  2001/11/21 03:11:28  richard
2236 # Better handling of new properties.
2238 # Revision 1.57  2001/11/15 10:24:27  richard
2239 # handle the case where there is no file attached
2241 # Revision 1.56  2001/11/14 21:35:21  richard
2242 #  . users may attach files to issues (and support in ext) through the web now
2244 # Revision 1.55  2001/11/07 02:34:06  jhermann
2245 # Handling of damaged login cookies
2247 # Revision 1.54  2001/11/07 01:16:12  richard
2248 # Remove the '=' padding from cookie value so quoting isn't an issue.
2250 # Revision 1.53  2001/11/06 23:22:05  jhermann
2251 # More IE fixes: it does not like quotes around cookie values; in the
2252 # hope this does not break anything for other browser; if it does, we
2253 # need to check HTTP_USER_AGENT
2255 # Revision 1.52  2001/11/06 23:11:22  jhermann
2256 # Fixed debug output in page footer; added expiry date to the login cookie
2257 # (expires 1 year in the future) to prevent probs with certain versions
2258 # of IE
2260 # Revision 1.51  2001/11/06 22:00:34  jhermann
2261 # Get debug level from ROUNDUP_DEBUG env var
2263 # Revision 1.50  2001/11/05 23:45:40  richard
2264 # Fixed newuser_action so it sets the cookie with the unencrypted password.
2265 # Also made it present nicer error messages (not tracebacks).
2267 # Revision 1.49  2001/11/04 03:07:12  richard
2268 # Fixed various cookie-related bugs:
2269 #  . bug #477685 ] base64.decodestring breaks
2270 #  . bug #477837 ] lynx does not like the cookie
2271 #  . bug #477892 ] Password edit doesn't fix login cookie
2272 # Also closed a security hole - a logged-in user could edit another user's
2273 # details.
2275 # Revision 1.48  2001/11/03 01:30:18  richard
2276 # Oops. uses pagefoot now.
2278 # Revision 1.47  2001/11/03 01:29:28  richard
2279 # Login page didn't have all close tags.
2281 # Revision 1.46  2001/11/03 01:26:55  richard
2282 # possibly fix truncated base64'ed user:pass
2284 # Revision 1.45  2001/11/01 22:04:37  richard
2285 # Started work on supporting a pop3-fetching server
2286 # Fixed bugs:
2287 #  . bug #477104 ] HTML tag error in roundup-server
2288 #  . bug #477107 ] HTTP header problem
2290 # Revision 1.44  2001/10/28 23:03:08  richard
2291 # Added more useful header to the classic schema.
2293 # Revision 1.43  2001/10/24 00:01:42  richard
2294 # More fixes to lockout logic.
2296 # Revision 1.42  2001/10/23 23:56:03  richard
2297 # HTML typo
2299 # Revision 1.41  2001/10/23 23:52:35  richard
2300 # Fixed lock-out logic, thanks Roch'e for pointing out the problems.
2302 # Revision 1.40  2001/10/23 23:06:39  richard
2303 # Some cleanup.
2305 # Revision 1.39  2001/10/23 01:00:18  richard
2306 # Re-enabled login and registration access after lopping them off via
2307 # disabling access for anonymous users.
2308 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
2309 # a couple of bugs while I was there. Probably introduced a couple, but
2310 # things seem to work OK at the moment.
2312 # Revision 1.38  2001/10/22 03:25:01  richard
2313 # Added configuration for:
2314 #  . anonymous user access and registration (deny/allow)
2315 #  . filter "widget" location on index page (top, bottom, both)
2316 # Updated some documentation.
2318 # Revision 1.37  2001/10/21 07:26:35  richard
2319 # feature #473127: Filenames. I modified the file.index and htmltemplate
2320 #  source so that the filename is used in the link and the creation
2321 #  information is displayed.
2323 # Revision 1.36  2001/10/21 04:44:50  richard
2324 # bug #473124: UI inconsistency with Link fields.
2325 #    This also prompted me to fix a fairly long-standing usability issue -
2326 #    that of being able to turn off certain filters.
2328 # Revision 1.35  2001/10/21 00:17:54  richard
2329 # CGI interface view customisation section may now be hidden (patch from
2330 #  Roch'e Compaan.)
2332 # Revision 1.34  2001/10/20 11:58:48  richard
2333 # Catch errors in login - no username or password supplied.
2334 # Fixed editing of password (Password property type) thanks Roch'e Compaan.
2336 # Revision 1.33  2001/10/17 00:18:41  richard
2337 # Manually constructing cookie headers now.
2339 # Revision 1.32  2001/10/16 03:36:21  richard
2340 # CGI interface wasn't handling checkboxes at all.
2342 # Revision 1.31  2001/10/14 10:55:00  richard
2343 # Handle empty strings in HTML template Link function
2345 # Revision 1.30  2001/10/09 07:38:58  richard
2346 # Pushed the base code for the extended schema CGI interface back into the
2347 # code cgi_client module so that future updates will be less painful.
2348 # Also removed a debugging print statement from cgi_client.
2350 # Revision 1.29  2001/10/09 07:25:59  richard
2351 # Added the Password property type. See "pydoc roundup.password" for
2352 # implementation details. Have updated some of the documentation too.
2354 # Revision 1.28  2001/10/08 00:34:31  richard
2355 # Change message was stuffing up for multilinks with no key property.
2357 # Revision 1.27  2001/10/05 02:23:24  richard
2358 #  . roundup-admin create now prompts for property info if none is supplied
2359 #    on the command-line.
2360 #  . hyperdb Class getprops() method may now return only the mutable
2361 #    properties.
2362 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
2363 #    now support anonymous user access (read-only, unless there's an
2364 #    "anonymous" user, in which case write access is permitted). Login
2365 #    handling has been moved into cgi_client.Client.main()
2366 #  . The "extended" schema is now the default in roundup init.
2367 #  . The schemas have had their page headings modified to cope with the new
2368 #    login handling. Existing installations should copy the interfaces.py
2369 #    file from the roundup lib directory to their instance home.
2370 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
2371 #    Ping - has been removed.
2372 #  . Fixed a whole bunch of places in the CGI interface where we should have
2373 #    been returning Not Found instead of throwing an exception.
2374 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
2375 #    an item now throws an exception.
2377 # Revision 1.26  2001/09/12 08:31:42  richard
2378 # handle cases where mime type is not guessable
2380 # Revision 1.25  2001/08/29 05:30:49  richard
2381 # change messages weren't being saved when there was no-one on the nosy list.
2383 # Revision 1.24  2001/08/29 04:49:39  richard
2384 # didn't clean up fully after debugging :(
2386 # Revision 1.23  2001/08/29 04:47:18  richard
2387 # Fixed CGI client change messages so they actually include the properties
2388 # changed (again).
2390 # Revision 1.22  2001/08/17 00:08:10  richard
2391 # reverted back to sending messages always regardless of who is doing the web
2392 # edit. change notes weren't being saved. bleah. hackish.
2394 # Revision 1.21  2001/08/15 23:43:18  richard
2395 # Fixed some isFooTypes that I missed.
2396 # Refactored some code in the CGI code.
2398 # Revision 1.20  2001/08/12 06:32:36  richard
2399 # using isinstance(blah, Foo) now instead of isFooType
2401 # Revision 1.19  2001/08/07 00:24:42  richard
2402 # stupid typo
2404 # Revision 1.18  2001/08/07 00:15:51  richard
2405 # Added the copyright/license notice to (nearly) all files at request of
2406 # Bizar Software.
2408 # Revision 1.17  2001/08/02 06:38:17  richard
2409 # Roundupdb now appends "mailing list" information to its messages which
2410 # include the e-mail address and web interface address. Templates may
2411 # override this in their db classes to include specific information (support
2412 # instructions, etc).
2414 # Revision 1.16  2001/08/02 05:55:25  richard
2415 # Web edit messages aren't sent to the person who did the edit any more. No
2416 # message is generated if they are the only person on the nosy list.
2418 # Revision 1.15  2001/08/02 00:34:10  richard
2419 # bleah syntax error
2421 # Revision 1.14  2001/08/02 00:26:16  richard
2422 # Changed the order of the information in the message generated by web edits.
2424 # Revision 1.13  2001/07/30 08:12:17  richard
2425 # Added time logging and file uploading to the templates.
2427 # Revision 1.12  2001/07/30 06:26:31  richard
2428 # Added some documentation on how the newblah works.
2430 # Revision 1.11  2001/07/30 06:17:45  richard
2431 # Features:
2432 #  . Added ability for cgi newblah forms to indicate that the new node
2433 #    should be linked somewhere.
2434 # Fixed:
2435 #  . Fixed the agument handling for the roundup-admin find command.
2436 #  . Fixed handling of summary when no note supplied for newblah. Again.
2437 #  . Fixed detection of no form in htmltemplate Field display.
2439 # Revision 1.10  2001/07/30 02:37:34  richard
2440 # Temporary measure until we have decent schema migration...
2442 # Revision 1.9  2001/07/30 01:25:07  richard
2443 # Default implementation is now "classic" rather than "extended" as one would
2444 # expect.
2446 # Revision 1.8  2001/07/29 08:27:40  richard
2447 # Fixed handling of passed-in values in form elements (ie. during a
2448 # drill-down)
2450 # Revision 1.7  2001/07/29 07:01:39  richard
2451 # Added vim command to all source so that we don't get no steenkin' tabs :)
2453 # Revision 1.6  2001/07/29 04:04:00  richard
2454 # Moved some code around allowing for subclassing to change behaviour.
2456 # Revision 1.5  2001/07/28 08:16:52  richard
2457 # New issue form handles lack of note better now.
2459 # Revision 1.4  2001/07/28 00:34:34  richard
2460 # Fixed some non-string node ids.
2462 # Revision 1.3  2001/07/23 03:56:30  richard
2463 # oops, missed a config removal
2465 # Revision 1.2  2001/07/22 12:09:32  richard
2466 # Final commit of Grande Splite
2468 # Revision 1.1  2001/07/22 11:58:35  richard
2469 # More Grande Splite
2472 # vim: set filetype=python ts=4 sw=4 et si