diff --git a/roundup/cgi_client.py b/roundup/cgi_client.py
index 5a78b17cd3677cc040ac5043b5a6750cde15ca3f..1f1c02dd35b21eb939bb0ee7e2f8267a61dcfa9d 100644 (file)
--- a/roundup/cgi_client.py
+++ b/roundup/cgi_client.py
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
#
-# $Id: cgi_client.py,v 1.86 2001-12-20 15:43:01 rochecompaan Exp $
+# $Id: cgi_client.py,v 1.162 2002-08-23 04:42:30 richard Exp $
__doc__ = """
WWW request handler (also used in the stand-alone server).
"""
-import os, cgi, pprint, StringIO, urlparse, re, traceback, mimetypes
-import binascii, Cookie, time
+import os, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
+import binascii, Cookie, time, random
import roundupdb, htmltemplate, date, hyperdb, password
from roundup.i18n import _
class NotFound(ValueError):
pass
+def initialiseSecurity(security):
+ ''' Create some Permissions and Roles on the security object
+
+ This function is directly invoked by security.Security.__init__()
+ as a part of the Security object instantiation.
+ '''
+ security.addPermission(name="Web Registration",
+ description="User may register through the web")
+ p = security.addPermission(name="Web Access",
+ description="User may access the web interface")
+ security.addPermissionToRole('Admin', p)
+
+ # doing Role stuff through the web - make sure Admin can
+ p = security.addPermission(name="Web Roles",
+ description="User may manipulate user Roles through the web")
+ security.addPermissionToRole('Admin', p)
+
class Client:
'''
A note about login
there is no cookie). This allows them to modify the database, and all
modifications are attributed to the 'anonymous' user.
-
- Customisation
- -------------
- FILTER_POSITION - one of 'top', 'bottom', 'top and bottom'
- ANONYMOUS_ACCESS - one of 'deny', 'allow'
- ANONYMOUS_REGISTER - one of 'deny', 'allow'
-
- from the roundup class:
- INSTANCE_NAME - defaults to 'Roundup issue tracker'
-
+ Once a user logs in, they are assigned a session. The Client instance
+ keeps the nodeid of the session as the "session" attribute.
'''
- FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom'
- ANONYMOUS_ACCESS = 'deny' # one of 'deny', 'allow'
- ANONYMOUS_REGISTER = 'deny' # one of 'deny', 'allow'
def __init__(self, instance, request, env, form=None):
+ hyperdb.traceMark()
self.instance = instance
self.request = request
self.env = env
self.path = env['PATH_INFO']
self.split_path = self.path.split('/')
+ self.instance_path_name = env['INSTANCE_NAME']
+ url = self.env['SCRIPT_NAME'] + '/'
+ machine = self.env['SERVER_NAME']
+ port = self.env['SERVER_PORT']
+ if port != '80': machine = machine + ':' + port
+ self.base = urlparse.urlunparse(('http', env['HTTP_HOST'], url,
+ None, None, None))
if form is None:
self.form = cgi.FieldStorage(environ=env)
self.debug = 0
def getuid(self):
- return self.db.user.lookup(self.user)
+ try:
+ return self.db.user.lookup(self.user)
+ except KeyError:
+ if self.user is None:
+ # user is not logged in and username 'anonymous' doesn't
+ # exist in the database
+ err = _('anonymous users have read-only access only')
+ else:
+ err = _("sanity check: unknown user name `%s'")%self.user
+ raise Unauthorised, errmsg
- def header(self, headers={'Content-Type':'text/html'}):
+ def header(self, headers=None, response=200):
'''Put up the appropriate header.
'''
+ if headers is None:
+ headers = {'Content-Type':'text/html'}
if not headers.has_key('Content-Type'):
headers['Content-Type'] = 'text/html'
- self.request.send_response(200)
+ self.request.send_response(response)
for entry in headers.items():
self.request.send_header(*entry)
self.request.end_headers()
if self.debug:
self.headers_sent = headers
+ global_javascript = '''
+<script language="javascript">
+submitted = false;
+function submit_once() {
+ if (submitted) {
+ alert("Your request is being processed.\\nPlease be patient.");
+ return 0;
+ }
+ submitted = true;
+ return 1;
+}
+
+function help_window(helpurl, width, height) {
+ HelpWin = window.open('%(base)s%(instance_path_name)s/' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
+}
+
+</script>
+'''
+ def make_index_link(self, name):
+ '''Turn a configuration entry into a hyperlink...
+ '''
+ # get the link label and spec
+ spec = getattr(self.instance, name+'_INDEX')
+
+ d = {}
+ d[':sort'] = ','.join(map(urllib.quote, spec['SORT']))
+ d[':group'] = ','.join(map(urllib.quote, spec['GROUP']))
+ d[':filter'] = ','.join(map(urllib.quote, spec['FILTER']))
+ d[':columns'] = ','.join(map(urllib.quote, spec['COLUMNS']))
+ d[':pagesize'] = spec.get('PAGESIZE','50')
+
+ # snarf the filterspec
+ filterspec = spec['FILTERSPEC'].copy()
+
+ # now format the filterspec
+ for k, l in filterspec.items():
+ # fix up the CURRENT USER if needed (handle None too since that's
+ # the old flag value)
+ if l in (None, 'CURRENT USER'):
+ if not self.user:
+ continue
+ l = [self.db.user.lookup(self.user)]
+
+ # add
+ d[urllib.quote(k)] = ','.join(map(urllib.quote, l))
+
+ # finally, format the URL
+ return '<a href="%s?%s">%s</a>'%(spec['CLASS'],
+ '&'.join([k+'='+v for k,v in d.items()]), spec['LABEL'])
+
+
def pagehead(self, title, message=None):
- url = self.env['SCRIPT_NAME'] + '/'
- machine = self.env['SERVER_NAME']
- port = self.env['SERVER_PORT']
- if port != '80': machine = machine + ':' + port
- base = urlparse.urlunparse(('http', machine, url, None, None, None))
+ '''Display the page heading, with information about the tracker and
+ links to more information
+ '''
+
+ # include any important message
if message is not None:
message = _('<div class="system-msg">%(message)s</div>')%locals()
else:
message = ''
- style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
- user_name = self.user or ''
- if self.user == 'admin':
- admin_links = _(' | <a href="list_classes">Class List</a>' \
- ' | <a href="user">User List</a>')
- else:
- admin_links = ''
- if self.user not in (None, 'anonymous'):
- userid = self.db.user.lookup(self.user)
+
+ # style sheet (CSS)
+ style = open(os.path.join(self.instance.TEMPLATES, 'style.css')).read()
+
+ # figure who the user is
+ user_name = self.user
+ userid = self.db.user.lookup(user_name)
+ default_queries = 1
+ links = []
+ if user_name != 'anonymous':
+ try:
+ default_queries = self.db.user.get(userid, 'defaultqueries')
+ except KeyError:
+ pass
+
+ # figure all the header links
+ if default_queries:
+ if hasattr(self.instance, 'HEADER_INDEX_LINKS'):
+ for name in self.instance.HEADER_INDEX_LINKS:
+ spec = getattr(self.instance, name + '_INDEX')
+ # skip if we need to fill in the logged-in user id and
+ # we're anonymous
+ if (spec['FILTERSPEC'].has_key('assignedto') and
+ spec['FILTERSPEC']['assignedto'] in ('CURRENT USER',
+ None) and user_name == 'anonymous'):
+ continue
+ links.append(self.make_index_link(name))
+ else:
+ # no config spec - hard-code
+ links = [
+ _('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>'),
+ _('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>')
+ ]
+
+ user_info = _('<a href="login">Login</a>')
+ add_links = ''
+ if user_name != 'anonymous':
+ # add any personal queries to the menu
+ try:
+ queries = self.db.getclass('query')
+ except KeyError:
+ # no query class
+ queries = self.instance.dbinit.Class(self.db, "query",
+ klass=hyperdb.String(), name=hyperdb.String(),
+ url=hyperdb.String())
+ queries.setkey('name')
+ #queries.disableJournalling()
+ try:
+ qids = self.db.getclass('user').get(userid, 'queries')
+ except KeyError, e:
+ #self.db.getclass('user').addprop(queries=hyperdb.Multilink('query'))
+ qids = []
+ for qid in qids:
+ links.append('<a href=%s?%s>%s</a>'%(queries.get(qid, 'klass'),
+ queries.get(qid, 'url'), queries.get(qid, 'name')))
+
+ # if they're logged in, include links to their information,
+ # and the ability to add an issue
user_info = _('''
-<a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
<a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
''')%locals()
+
+ # figure the "add class" links
+ if hasattr(self.instance, 'HEADER_ADD_LINKS'):
+ classes = self.instance.HEADER_ADD_LINKS
else:
- user_info = _('<a href="login">Login</a>')
- if self.user is not None:
- if self.user == 'admin':
- add_links = _('''
-| Add
-<a href="newissue">Issue</a>,
-<a href="newuser">User</a>
-''')
- else:
- add_links = _('''
-| Add
-<a href="newissue">Issue</a>
-''')
+ classes = ['issue']
+ l = []
+ for class_name in classes:
+ # make sure the user has permission to add
+ if not self.db.security.hasPermission('Edit', userid, class_name):
+ continue
+ cap_class = class_name.capitalize()
+ links.append(_('Add <a href="new%(class_name)s">'
+ '%(cap_class)s</a>')%locals())
+
+ # if the user can edit everything, include the links
+ admin_links = ''
+ userid = self.db.user.lookup(user_name)
+ if self.db.security.hasPermission('Edit', userid):
+ links.append(_('<a href="list_classes">Class List</a>'))
+ links.append(_('<a href="user?:sort=username&:group=roles">User List</a>'))
+ links.append(_('<a href="newuser">Add User</a>'))
+
+ # add the search links
+ if hasattr(self.instance, 'HEADER_SEARCH_LINKS'):
+ classes = self.instance.HEADER_SEARCH_LINKS
else:
- add_links = ''
+ classes = ['issue']
+ l = []
+ for class_name in classes:
+ # make sure the user has permission to view
+ if not self.db.security.hasPermission('View', userid, class_name):
+ continue
+ cap_class = class_name.capitalize()
+ links.append(_('Search <a href="search%(class_name)s">'
+ '%(cap_class)s</a>')%locals())
+
+ # now we have all the links, join 'em
+ links = '\n | '.join(links)
+
+ # include the javascript bit
+ global_javascript = self.global_javascript%self.__dict__
+
+ # finally, format the header
self.write(_('''<html><head>
<title>%(title)s</title>
<style type="text/css">%(style)s</style>
</head>
+%(global_javascript)s
<body bgcolor=#ffffff>
%(message)s
<table width=100%% border=0 cellspacing=0 cellpadding=2>
-<tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
-<td align=right valign=bottom>%(user_name)s</td></tr>
-<tr class="location-bar">
-<td align=left>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>
-| 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>
-%(add_links)s
-%(admin_links)s</td>
-<td align=right>%(user_info)s</td>
-</table>
+ <tr class="location-bar">
+ <td><big><strong>%(title)s</strong></big></td>
+ <td align=right valign=bottom>%(user_name)s</td>
+ </tr>
+ <tr class="location-bar">
+ <td align=left>%(links)s</td>
+ <td align=right>%(user_info)s</td>
+ </tr>
+</table><br>
''')%locals())
def pagefoot(self):
return arg.value.split(',')
return []
- def index_filterspec(self, filter):
+ def index_sort(self):
+ # first try query string / simple form
+ x = self.index_arg(':sort')
+ if x:
+ if self.index_arg(':descending'):
+ return ['-'+x[0]]
+ return x
+ # nope - get the specs out of the form
+ specs = []
+ for colnm in self.db.getclass(self.classname).getprops().keys():
+ desc = ''
+ try:
+ spec = self.form[':%s_ss' % colnm]
+ except KeyError:
+ continue
+ spec = spec.value
+ if spec:
+ if spec[-1] == '-':
+ desc='-'
+ spec = spec[0]
+ specs.append((int(spec), colnm, desc))
+ specs.sort()
+ x = []
+ for _, colnm, desc in specs:
+ x.append('%s%s' % (desc, colnm))
+ return x
+
+ def index_filterspec(self, filter, classname=None):
''' pull the index filter spec from the form
Links and multilinks want to be lists - the rest are straight
strings.
'''
- props = self.db.classes[self.classname].getprops()
- # all the form args not starting with ':' are filters
+ if classname is None:
+ classname = self.classname
+ klass = self.db.getclass(classname)
filterspec = {}
- for key in self.form.keys():
- if key[0] == ':': continue
- if not props.has_key(key): continue
- if key not in filter: continue
- prop = props[key]
- value = self.form[key]
- if (isinstance(prop, hyperdb.Link) or
- isinstance(prop, hyperdb.Multilink)):
- if type(value) == type([]):
- value = [arg.value for arg in value]
+ props = klass.getprops()
+ for colnm in filter:
+ widget = ':%s_fs' % colnm
+ try:
+ val = self.form[widget]
+ except KeyError:
+ try:
+ val = self.form[colnm]
+ except KeyError:
+ # they checked the filter box but didn't enter a value
+ continue
+ propdescr = props.get(colnm, None)
+ if propdescr is None:
+ print "huh? %r is in filter & form, but not in Class!" % colnm
+ raise "butthead programmer"
+ if (isinstance(propdescr, hyperdb.Link) or
+ isinstance(propdescr, hyperdb.Multilink)):
+ if type(val) == type([]):
+ val = [arg.value for arg in val]
else:
- value = value.value.split(',')
- l = filterspec.get(key, [])
- l = l + value
- filterspec[key] = l
+ val = val.value.split(',')
+ l = filterspec.get(colnm, [])
+ l = l + val
+ filterspec[colnm] = l
else:
- filterspec[key] = value.value
+ filterspec[colnm] = val.value
+
return filterspec
-
+
def customization_widget(self):
''' The customization widget is visible by default. The widget
visibility is remembered by show_customization. Visibility
return visible
+ # TODO: make this go away some day...
default_index_sort = ['-activity']
default_index_group = ['priority']
default_index_filter = ['status']
default_index_columns = ['id','activity','title','status','assignedto']
default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
- def index(self):
- ''' put up an index
- '''
- self.classname = 'issue'
+ default_pagesize = '50'
+
+ def _get_customisation_info(self):
# see if the web has supplied us with any customisation info
- defaults = 1
- for key in ':sort', ':group', ':filter', ':columns':
+ for key in ':sort', ':group', ':filter', ':columns', ':pagesize':
if self.form.has_key(key):
- defaults = 0
+ # make list() extract the info from the CGI environ
+ self.classname = 'issue'
+ sort = group = filter = columns = filterspec = pagesize = None
break
- if defaults:
- # no info supplied - use the defaults
- sort = self.default_index_sort
- group = self.default_index_group
- filter = self.default_index_filter
- columns = self.default_index_columns
- filterspec = self.default_index_filterspec
else:
- sort = self.index_arg(':sort')
- group = self.index_arg(':group')
- filter = self.index_arg(':filter')
- columns = self.index_arg(':columns')
- filterspec = self.index_filterspec(filter)
+ # TODO: look up the session first
+ # try the instance config first
+ if hasattr(self.instance, 'DEFAULT_INDEX'):
+ d = self.instance.DEFAULT_INDEX
+ self.classname = d['CLASS']
+ sort = d['SORT']
+ group = d['GROUP']
+ filter = d['FILTER']
+ columns = d['COLUMNS']
+ filterspec = d['FILTERSPEC']
+ pagesize = d.get('PAGESIZE', '50')
+ else:
+ # nope - fall back on the old way of doing it
+ self.classname = 'issue'
+ sort = self.default_index_sort
+ group = self.default_index_group
+ filter = self.default_index_filter
+ columns = self.default_index_columns
+ filterspec = self.default_index_filterspec
+ pagesize = self.default_pagesize
+ return columns, filter, group, sort, filterspec, pagesize
+
+ def index(self):
+ ''' put up an index - no class specified
+ '''
+ columns, filter, group, sort, filterspec, pagesize = \
+ self._get_customisation_info()
return self.list(columns=columns, filter=filter, group=group,
- sort=sort, filterspec=filterspec)
+ sort=sort, filterspec=filterspec, pagesize=pagesize)
+
+ def searchnode(self):
+ columns, filter, group, sort, filterspec, pagesize = \
+ self._get_customisation_info()
+ cn = self.classname
+ self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
+ 'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
+ index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
+ self.write('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
+ all_columns = self.db.getclass(cn).getprops().keys()
+ all_columns.sort()
+ index.filter_section('', filter, columns, group, all_columns, sort,
+ filterspec, pagesize, 0, 0)
+ self.pagefoot()
# XXX deviates from spec - loses the '+' (that's a reserved character
# in URLS
def list(self, sort=None, group=None, filter=None, columns=None,
- filterspec=None, show_customization=None):
+ filterspec=None, show_customization=None, show_nodes=1,
+ pagesize=None):
''' call the template index with the args
:sort - sort by prop name, optionally preceeded with '-'
'''
cn = self.classname
cl = self.db.classes[cn]
- self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
- 'classname': cn, 'instancename': self.INSTANCE_NAME})
- if sort is None: sort = self.index_arg(':sort')
+ if sort is None: sort = self.index_sort()
if group is None: group = self.index_arg(':group')
if filter is None: filter = self.index_arg(':filter')
if columns is None: columns = self.index_arg(':columns')
if filterspec is None: filterspec = self.index_filterspec(filter)
if show_customization is None:
show_customization = self.customization_widget()
+ if self.form.has_key('search_text'):
+ search_text = self.form['search_text'].value
+ else:
+ search_text = ''
+ if pagesize is None:
+ if self.form.has_key(':pagesize'):
+ pagesize = self.form[':pagesize'].value
+ else:
+ pagesize = '50'
+ pagesize = int(pagesize)
+ if self.form.has_key(':startwith'):
+ startwith = int(self.form[':startwith'].value)
+ else:
+ startwith = 0
+ simpleform = 1
+ if self.form.has_key(':advancedsearch'):
+ simpleform = 0
+
+ if self.form.has_key('Query') and self.form['Query'].value == 'Save':
+ # format a query string
+ qd = {}
+ qd[':sort'] = ','.join(map(urllib.quote, sort))
+ qd[':group'] = ','.join(map(urllib.quote, group))
+ qd[':filter'] = ','.join(map(urllib.quote, filter))
+ qd[':columns'] = ','.join(map(urllib.quote, columns))
+ for k, l in filterspec.items():
+ qd[urllib.quote(k)] = ','.join(map(urllib.quote, l))
+ url = '&'.join([k+'='+v for k,v in qd.items()])
+ url += '&:pagesize=%s' % pagesize
+ if search_text:
+ url += '&search_text=%s' % search_text
+
+ # create a query
+ d = {}
+ d['name'] = nm = self.form[':name'].value
+ if not nm:
+ d['name'] = nm = 'New Query'
+ d['klass'] = self.form[':classname'].value
+ d['url'] = url
+ qid = self.db.getclass('query').create(**d)
+
+ # and add it to the user's query multilink
+ uid = self.getuid()
+ usercl = self.db.getclass('user')
+ queries = usercl.get(uid, 'queries')
+ queries.append(qid)
+ usercl.set(uid, queries=queries)
+
+ self.pagehead(_('%(instancename)s: Index of %(classname)s')%{
+ 'classname': cn, 'instancename': self.instance.INSTANCE_NAME})
- index = htmltemplate.IndexTemplate(self, self.TEMPLATES, cn)
- index.render(filterspec, filter, columns, sort, group,
- show_customization=show_customization)
+ index = htmltemplate.IndexTemplate(self, self.instance.TEMPLATES, cn)
+ try:
+ index.render(filterspec=filterspec, search_text=search_text,
+ filter=filter, columns=columns, sort=sort, group=group,
+ show_customization=show_customization,
+ show_nodes=show_nodes, pagesize=pagesize, startwith=startwith,
+ simple_search=simpleform)
+ except htmltemplate.MissingTemplateError:
+ self.basicClassEditPage()
self.pagefoot()
- def shownode(self, message=None):
- ''' display an item
+ def basicClassEditPage(self):
+ '''Display a basic edit page that allows simple editing of the
+ nodes of the current class
'''
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('Edit', userid):
+ raise Unauthorised, _("You do not have permission to access"\
+ " %(action)s.")%{'action': self.classname}
+ w = self.write
cn = self.classname
cl = self.db.classes[cn]
+ idlessprops = cl.getprops(protected=0).keys()
+ props = ['id'] + idlessprops
- # possibly perform an edit
+ # get the CSV module
+ try:
+ import csv
+ except ImportError:
+ w(_('Sorry, you need the csv module to use this function.<br>\n'
+ 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/'))
+ return
+
+ # do the edit
+ if self.form.has_key('rows'):
+ rows = self.form['rows'].value.splitlines()
+ p = csv.parser()
+ found = {}
+ line = 0
+ for row in rows:
+ line += 1
+ values = p.parse(row)
+ # not a complete row, keep going
+ if not values: continue
+
+ # extract the nodeid
+ nodeid, values = values[0], values[1:]
+ found[nodeid] = 1
+
+ # confirm correct weight
+ if len(idlessprops) != len(values):
+ w(_('Not enough values on line %(line)s'%{'line':line}))
+ return
+
+ # extract the new values
+ d = {}
+ for name, value in zip(idlessprops, values):
+ value = value.strip()
+ # only add the property if it has a value
+ if value:
+ # if it's a multilink, split it
+ if isinstance(cl.properties[name], hyperdb.Multilink):
+ value = value.split(':')
+ d[name] = value
+
+ # perform the edit
+ if cl.hasnode(nodeid):
+ # edit existing
+ cl.set(nodeid, **d)
+ else:
+ # new node
+ found[cl.create(**d)] = 1
+
+ # retire the removed entries
+ for nodeid in cl.list():
+ if not found.has_key(nodeid):
+ cl.retire(nodeid)
+
+ w(_('''<p class="form-help">You may edit the contents of the
+ "%(classname)s" class using this form. Commas, newlines and double
+ quotes (") must be handled delicately. You may include commas and
+ newlines by enclosing the values in double-quotes ("). Double
+ quotes themselves must be quoted by doubling ("").</p>
+ <p class="form-help">Multilink properties have their multiple
+ values colon (":") separated (... ,"one:two:three", ...)</p>
+ <p class="form-help">Remove entries by deleting their line. Add
+ new entries by appending
+ them to the table - put an X in the id column.</p>''')%{'classname':cn})
+
+ l = []
+ for name in props:
+ l.append(name)
+ w('<tt>')
+ w(', '.join(l) + '\n')
+ w('</tt>')
+
+ w('<form onSubmit="return submit_once()" method="POST">')
+ w('<textarea name="rows" cols=80 rows=15>')
+ p = csv.parser()
+ for nodeid in cl.list():
+ l = []
+ for name in props:
+ value = cl.get(nodeid, name)
+ if value is None:
+ l.append('')
+ elif isinstance(value, type([])):
+ l.append(cgi.escape(':'.join(map(str, value))))
+ else:
+ l.append(cgi.escape(str(cl.get(nodeid, name))))
+ w(p.join(l) + '\n')
+
+ w(_('</textarea><br><input type="submit" value="Save Changes"></form>'))
+
+ def classhelp(self):
+ '''Display a table of class info
+ '''
+ w = self.write
+ cn = self.form['classname'].value
+ cl = self.db.classes[cn]
+ props = self.form['properties'].value.split(',')
+ if cl.labelprop(1) in props:
+ sort = [cl.labelprop(1)]
+ else:
+ sort = props[0]
+
+ w('<table border=1 cellspacing=0 cellpaddin=2>')
+ w('<tr>')
+ for name in props:
+ w('<th align=left>%s</th>'%name)
+ w('</tr>')
+ for nodeid in cl.filter(None, {}, sort, []):
+ w('<tr>')
+ for name in props:
+ value = cgi.escape(str(cl.get(nodeid, name)))
+ w('<td align="left" valign="top">%s</td>'%value)
+ w('</tr>')
+ w('</table>')
+
+ def shownode(self, message=None, num_re=re.compile('^\d+$')):
+ ''' display an item
+ '''
+ cn = self.classname
+ cl = self.db.classes[cn]
keys = self.form.keys()
- num_re = re.compile('^\d+$')
+ fromremove = 0
+ if self.form.has_key(':multilink'):
+ # is the multilink there because we came from remove()?
+ if self.form.has_key(':target'):
+ xtra = ''
+ fromremove = 1
+ message = _('%s removed' % self.index_arg(":target")[0])
+ else:
+ link = self.form[':multilink'].value
+ designator, linkprop = link.split(':')
+ xtra = ' for <a href="%s">%s</a>' % (designator, designator)
+ else:
+ xtra = ''
+
+ # possibly perform an edit
# don't try to set properties if the user has just logged in
- if keys and not self.form.has_key('__login_name'):
+ if keys and not fromremove and not self.form.has_key('__login_name'):
try:
- props, changed = parsePropsFromForm(self.db, cl, self.form,
- self.nodeid)
- # make changes to the node
- self._changenode(props)
- # handle linked nodes
- self._post_editnode(self.nodeid)
- # and some nice feedback for the user
- if changed:
- message = _('%(changes)s edited ok')%{'changes':
- ', '.join(changed.keys())}
- elif self.form.has_key('__note') and self.form['__note'].value:
- message = _('note added')
- elif self.form.has_key('__file'):
- message = _('file added')
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('Edit', userid, cn):
+ message = _('You do not have permission to edit %s' %cn)
else:
- message = _('nothing changed')
+ props = parsePropsFromForm(self.db, cl, self.form, self.nodeid)
+ # make changes to the node
+ props = self._changenode(props)
+ # handle linked nodes
+ self._post_editnode(self.nodeid)
+ # and some nice feedback for the user
+ if props:
+ message = _('%(changes)s edited ok')%{'changes':
+ ', '.join(props.keys())}
+ elif self.form.has_key('__note') and self.form['__note'].value:
+ message = _('note added')
+ elif (self.form.has_key('__file') and
+ self.form['__file'].filename):
+ message = _('file added')
+ else:
+ message = _('nothing changed')
except:
self.db.rollback()
s = StringIO.StringIO()
id = self.nodeid
if cl.getkey():
id = cl.get(id, cl.getkey())
- self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
+ self.pagehead('%s: %s %s'%(self.classname.capitalize(), id, xtra),
+ message)
nodeid = self.nodeid
# use the template to display the item
- item = htmltemplate.ItemTemplate(self, self.TEMPLATES, self.classname)
+ item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
+ self.classname)
item.render(nodeid)
self.pagefoot()
showissue = shownode
showmsg = shownode
+ searchissue = searchnode
+
+ def showquery(self):
+ queries = self.db.getclass(self.classname)
+ if self.form.keys():
+ sort = self.index_sort()
+ group = self.index_arg(':group')
+ filter = self.index_arg(':filter')
+ columns = self.index_arg(':columns')
+ filterspec = self.index_filterspec(filter, queries.get(self.nodeid, 'klass'))
+ if self.form.has_key('search_text'):
+ search_text = self.form['search_text'].value
+ search_text = urllib.quote(search_text)
+ else:
+ search_text = ''
+ if self.form.has_key(':pagesize'):
+ pagesize = int(self.form[':pagesize'].value)
+ else:
+ pagesize = 50
+ # format a query string
+ qd = {}
+ qd[':sort'] = ','.join(map(urllib.quote, sort))
+ qd[':group'] = ','.join(map(urllib.quote, group))
+ qd[':filter'] = ','.join(map(urllib.quote, filter))
+ qd[':columns'] = ','.join(map(urllib.quote, columns))
+ for k, l in filterspec.items():
+ qd[urllib.quote(k)] = ','.join(map(urllib.quote, l))
+ url = '&'.join([k+'='+v for k,v in qd.items()])
+ url += '&:pagesize=%s' % pagesize
+ if search_text:
+ url += '&search_text=%s' % search_text
+ if url != queries.get(self.nodeid, 'url'):
+ queries.set(self.nodeid, url=url)
+ message = _('url edited ok')
+ else:
+ message = _('nothing changed')
+ else:
+ message = None
+ nm = queries.get(self.nodeid, 'name')
+ self.pagehead('%s: %s'%(self.classname.capitalize(), nm), message)
+ # use the template to display the item
+ item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
+ self.classname)
+ item.render(self.nodeid)
+ self.pagefoot()
+
def _changenode(self, props):
''' change the node based on the contents of the form
'''
cl = self.db.classes[self.classname]
- # set status to chatting if 'unread' or 'resolved'
- try:
- # determine the id of 'unread','resolved' and 'chatting'
- unread_id = self.db.status.lookup('unread')
- resolved_id = self.db.status.lookup('resolved')
- chatting_id = self.db.status.lookup('chatting')
- current_status = cl.get(self.nodeid, 'status')
- except KeyError:
- pass
- else:
- if (props['status'] == unread_id or props['status'] == resolved_id and current_status == resolved_id):
- props['status'] = chatting_id
- # add assignedto to the nosy list
- if props.has_key('assignedto'):
- assignedto_id = props['assignedto']
- if assignedto_id not in props['nosy']:
- props['nosy'].append(assignedto_id)
+
# create the message
message, files = self._handle_message()
if message:
props['messages'] = cl.get(self.nodeid, 'messages') + [message]
if files:
props['files'] = cl.get(self.nodeid, 'files') + files
+
# make the changes
- cl.set(self.nodeid, **props)
+ return cl.set(self.nodeid, **props)
def _createnode(self):
''' create a node based on the contents of the form
'''
cl = self.db.classes[self.classname]
- props, dummy = parsePropsFromForm(self.db, cl, self.form)
+ props = parsePropsFromForm(self.db, cl, self.form)
- # set status to 'unread' if not specified - a status of '- no
- # selection -' doesn't make sense
- if not props.has_key('status'):
- try:
- unread_id = self.db.status.lookup('unread')
- except KeyError:
- pass
- else:
- props['status'] = unread_id
- # add assignedto to the nosy list
- if props.has_key('assignedto'):
- assignedto_id = props['assignedto']
- if props.has_key('nosy') and assignedto_id not in props['nosy']:
- props['nosy'].append(assignedto_id)
- else:
- props['nosy'] = [assignedto_id]
# check for messages and files
message, files = self._handle_message()
if message:
return cl.create(**props)
def _handle_message(self):
- ''' generate and edit message
+ ''' generate an edit message
'''
# handle file attachments
files = []
props = cl.getprops()
note = None
# in a nutshell, don't do anything if there's no note or there's no
- # nosy
+ # NOSY
if self.form.has_key('__note'):
- note = self.form['__note'].value
+ note = self.form['__note'].value.strip()
+ if not note:
+ return None, files
if not props.has_key('messages'):
return None, files
if not isinstance(props['messages'], hyperdb.Multilink):
return None, files
# handle the note
- if note:
- if '\n' in note:
- summary = re.split(r'\n\r?', note)[0]
- else:
- summary = note
- m = ['%s\n'%note]
- elif not files:
- # don't generate a useless message
- return None, files
+ if '\n' in note:
+ summary = re.split(r'\n\r?', note)[0]
+ else:
+ summary = note
+ m = ['%s\n'%note]
+
+ # handle the messageid
+ # TODO: handle inreplyto
+ messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(),
+ self.classname, self.instance.MAIL_DOMAIN)
# now create the message, attaching the files
content = '\n'.join(m)
message_id = self.db.msg.create(author=self.getuid(),
recipients=[], date=date.Date('.'), summary=summary,
- content=content, files=files)
+ content=content, files=files, messageid=messageid)
# update the messages property
return message_id, files
if type(value) != type([]): value = [value]
for value in value:
designator, property = value.split(':')
- link, nodeid = roundupdb.splitDesignator(designator)
+ link, nodeid = hyperdb.splitDesignator(designator)
link = self.db.classes[link]
- value = link.get(nodeid, property)
+ # take a dupe of the list so we're not changing the cache
+ value = link.get(nodeid, property)[:]
value.append(nid)
link.set(nodeid, **{property: value})
elif key == ':link':
if type(value) != type([]): value = [value]
for value in value:
designator, property = value.split(':')
- link, nodeid = roundupdb.splitDesignator(designator)
+ link, nodeid = hyperdb.splitDesignator(designator)
link = self.db.classes[link]
link.set(nodeid, **{property: nid})
node's id. The node id will be appended to the multilink.
'''
cn = self.classname
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('View', userid, cn):
+ raise Unauthorised, _("You do not have permission to access"\
+ " %(action)s.")%{'action': self.classname}
cl = self.db.classes[cn]
+ if self.form.has_key(':multilink'):
+ link = self.form[':multilink'].value
+ designator, linkprop = link.split(':')
+ xtra = ' for <a href="%s">%s</a>' % (designator, designator)
+ else:
+ xtra = ''
# possibly perform a create
keys = self.form.keys()
if [i for i in keys if i[0] != ':']:
+ # no dice if you can't edit!
+ if not self.db.security.hasPermission('Edit', userid, cn):
+ raise Unauthorised, _("You do not have permission to access"\
+ " %(action)s.")%{'action': 'new'+self.classname}
props = {}
try:
nid = self._createnode()
self.nodeid = nid
self.pagehead('%s: %s'%(self.classname.capitalize(), nid),
message)
- item = htmltemplate.ItemTemplate(self, self.TEMPLATES,
+ item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES,
self.classname)
item.render(nid)
self.pagefoot()
s = StringIO.StringIO()
traceback.print_exc(None, s)
message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
- self.pagehead(_('New %(classname)s')%{'classname':
- self.classname.capitalize()}, message)
+ self.pagehead(_('New %(classname)s %(xtra)s')%{
+ 'classname': self.classname.capitalize(),
+ 'xtra': xtra }, message)
# call the template
- newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
+ newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
self.classname)
newitem.render(self.form)
Don't do any of the message or file handling, just create the node.
'''
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('Edit', userid, 'user'):
+ raise Unauthorised, _("You do not have permission to access"\
+ " %(action)s.")%{'action': 'newuser'}
+
cn = self.classname
cl = self.db.classes[cn]
keys = self.form.keys()
if [i for i in keys if i[0] != ':']:
try:
- props, dummy = parsePropsFromForm(self.db, cl, self.form)
+ props = parsePropsFromForm(self.db, cl, self.form)
nid = cl.create(**props)
# handle linked nodes
self._post_editnode(nid)
self.classname.capitalize()}, message)
# call the template
- newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
+ newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
self.classname)
newitem.render(self.form)
This form works very much the same way as newnode - it just has a
file upload.
'''
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('Edit', userid, 'file'):
+ raise Unauthorised, _("You do not have permission to access"\
+ " %(action)s.")%{'action': 'newfile'}
cn = self.classname
cl = self.db.classes[cn]
+ props = parsePropsFromForm(self.db, cl, self.form)
+ if self.form.has_key(':multilink'):
+ link = self.form[':multilink'].value
+ designator, linkprop = link.split(':')
+ xtra = ' for <a href="%s">%s</a>' % (designator, designator)
+ else:
+ xtra = ''
# possibly perform a create
keys = self.form.keys()
if not mime_type:
mime_type = "application/octet-stream"
# save the file
- nid = cl.create(content=file.file.read(), type=mime_type,
- name=file.filename)
+ props['type'] = mime_type
+ props['name'] = file.filename
+ props['content'] = file.file.read()
+ nid = cl.create(**props)
# handle linked nodes
self._post_editnode(nid)
# and some nice feedback for the user
traceback.print_exc(None, s)
message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
- self.pagehead(_('New %(classname)s')%{'classname':
- self.classname.capitalize()}, message)
- newitem = htmltemplate.NewItemTemplate(self, self.TEMPLATES,
+ self.pagehead(_('New %(classname)s %(xtra)s')%{
+ 'classname': self.classname.capitalize(),
+ 'xtra': xtra }, message)
+ newitem = htmltemplate.NewItemTemplate(self, self.instance.TEMPLATES,
self.classname)
newitem.render(self.form)
self.pagefoot()
- def showuser(self, message=None):
+ def showuser(self, message=None, num_re=re.compile('^\d+$')):
'''Display a user page for editing. Make sure the user is allowed
- to edit this node, and also check for password changes.
- '''
- if self.user == 'anonymous':
- raise Unauthorised
+ to edit this node, and also check for password changes.
+ Note: permission checks for this node are handled in the template.
+ '''
user = self.db.user
# get the username of the node being edited
- node_user = user.get(self.nodeid, 'username')
-
- if self.user not in ('admin', node_user):
- raise Unauthorised
+ try:
+ node_user = user.get(self.nodeid, 'username')
+ except IndexError:
+ raise NotFound, 'user%s'%self.nodeid
#
# perform any editing
#
keys = self.form.keys()
- num_re = re.compile('^\d+$')
if keys:
try:
- props, changed = parsePropsFromForm(self.db, user, self.form,
+ props = parsePropsFromForm(self.db, user, self.form,
self.nodeid)
set_cookie = 0
- if self.nodeid == self.getuid() and changed.has_key('password'):
+ if props.has_key('password'):
password = self.form['password'].value.strip()
- if password:
- set_cookie = password
- else:
+ if not password:
# no password was supplied - don't change it
del props['password']
- del changed['password']
+ elif self.nodeid == self.getuid():
+ # this is the logged-in user's password
+ set_cookie = password
user.set(self.nodeid, **props)
# and some feedback for the user
message = _('%(changes)s edited ok')%{'changes':
- ', '.join(changed.keys())}
+ ', '.join(props.keys())}
except:
self.db.rollback()
s = StringIO.StringIO()
self.pagehead(_('User: %(user)s')%{'user': node_user}, message)
# use the template to display the item
- item = htmltemplate.ItemTemplate(self, self.TEMPLATES, 'user')
+ item = htmltemplate.ItemTemplate(self, self.instance.TEMPLATES, 'user')
item.render(self.nodeid)
self.pagefoot()
def showfile(self):
''' display a file
'''
+ # nothing in xtrapath - edit the file's metadata
+ if self.xtrapath is None:
+ return self.shownode()
+
+ # something in xtrapath - download the file
nodeid = self.nodeid
- cl = self.db.file
- mime_type = cl.get(nodeid, 'type')
+ cl = self.db.classes[self.classname]
+ try:
+ mime_type = cl.get(nodeid, 'type')
+ except IndexError:
+ raise NotFound, 'file%s'%nodeid
if mime_type == 'message/rfc822':
mime_type = 'text/plain'
self.header(headers={'Content-Type': mime_type})
self.write(cl.get(nodeid, 'content'))
+
+ def permission(self):
+ '''
+ '''
def classes(self, message=None):
''' display a list of all the classes in the database
'''
- if self.user == 'admin':
- self.pagehead(_('Table of classes'), message)
- classnames = self.db.classes.keys()
- classnames.sort()
- self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
- for cn in classnames:
- cl = self.db.getclass(cn)
- self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
- for key, value in cl.properties.items():
- if value is None: value = ''
- else: value = str(value)
- self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
- key, cgi.escape(value)))
- self.write('</table>')
- self.pagefoot()
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('Edit', userid):
+ raise Unauthorised, _("You do not have permission to access"\
+ " %(action)s.")%{'action': 'all classes'}
+
+ self.pagehead(_('Table of classes'), message)
+ classnames = self.db.classes.keys()
+ classnames.sort()
+ self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
+ for cn in classnames:
+ cl = self.db.getclass(cn)
+ self.write('<tr class="list-header"><th colspan=2 align=left>'
+ '<a href="%s">%s</a></th></tr>'%(cn, cn.capitalize()))
+ for key, value in cl.properties.items():
+ if value is None: value = ''
+ else: value = str(value)
+ self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
+ key, cgi.escape(value)))
+ self.write('</table>')
+ self.pagefoot()
+
+ def unauthorised(self, message):
+ ''' The user is not authorised to do something. If they're
+ anonymous, throw up a login box. If not, just tell them they
+ can't do whatever it was they were trying to do.
+
+ Bot cases print up the message, which is most likely the
+ argument to the Unauthorised exception.
+ '''
+ self.header(response=403)
+ if self.desired_action is None or self.desired_action == 'login':
+ if not message:
+ message=_("You do not have permission.")
+ action = 'index'
else:
- raise Unauthorised
+ if not message:
+ message=_("You do not have permission to access"\
+ " %(action)s.")%{'action': self.desired_action}
+ action = self.desired_action
+ if self.user == 'anonymous':
+ self.login(action=action, message=message)
+ else:
+ self.pagehead(_('Not Authorised'))
+ self.write('<p class="system-msg">%s</p>'%message)
+ self.pagefoot()
def login(self, message=None, newuser_form=None, action='index'):
'''Display a login page.
'''
- self.pagehead(_('Login to roundup'), message)
+ self.pagehead(_('Login to roundup'))
+ if message:
+ self.write('<p class="system-msg">%s</p>'%message)
self.write(_('''
<table>
<tr><td colspan=2 class="strong-header">Existing User Login</td></tr>
-<form action="login_action" method=POST>
+<form onSubmit="return submit_once()" action="login_action" method=POST>
<input type="hidden" name="__destination_url" value="%(action)s">
<tr><td align=right>Login name: </td>
<td><input name="__login_name"></td></tr>
<td><input type="submit" value="Log In"></td></tr>
</form>
''')%locals())
- if self.user is None and self.ANONYMOUS_REGISTER == 'deny':
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('Web Registration', userid):
self.write('</table>')
self.pagefoot()
return
values = {'realname': '', 'organisation': '', 'address': '',
'phone': '', 'username': '', 'password': '', 'confirm': '',
- 'action': action}
+ 'action': action, 'alternate_addresses': ''}
if newuser_form is not None:
for key in newuser_form.keys():
values[key] = newuser_form[key].value
<p>
<tr><td colspan=2 class="strong-header">New User Registration</td></tr>
<tr><td colspan=2><em>marked items</em> are optional...</td></tr>
-<form action="newuser_action" method=POST>
+<form onSubmit="return submit_once()" action="newuser_action" method=POST>
<input type="hidden" name="__destination_url" value="%(action)s">
<tr><td align=right><em>Name: </em></td>
- <td><input name="realname" value="%(realname)s"></td></tr>
+ <td><input name="realname" value="%(realname)s" size=40></td></tr>
<tr><td align=right><em>Organisation: </em></td>
- <td><input name="organisation" value="%(organisation)s"></td></tr>
+ <td><input name="organisation" value="%(organisation)s" size=40></td></tr>
<tr><td align=right>E-Mail Address: </td>
- <td><input name="address" value="%(address)s"></td></tr>
+ <td><input name="address" value="%(address)s" size=40></td></tr>
+<tr><td align=right><em>Alternate E-mail Addresses: </em></td>
+ <td><textarea name="alternate_addresses" rows=5 cols=40>%(alternate_addresses)s</textarea></td></tr>
<tr><td align=right><em>Phone: </em></td>
<td><input name="phone" value="%(phone)s"></td></tr>
<tr><td align=right>Preferred Login name: </td>
self.login(message=_('Username required'))
return 0
self.user = self.form['__login_name'].value
+ # re-open the database for real, using the user
+ self.opendb(self.user)
if self.form.has_key('__login_password'):
password = self.form['__login_password'].value
else:
return 1 on successful login
'''
- # re-open the database as "admin"
- self.db = self.instance.open('admin')
+ # make sure we're allowed to register
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('Web Registration', userid):
+ raise Unauthorised, _("You do not have permission to access"\
+ " %(action)s.")%{'action': 'registration'}
- # TODO: pre-check the required fields and username key property
+ # re-open the database as "admin"
+ if self.user != 'admin':
+ self.opendb('admin')
+
+ # create the new user
cl = self.db.user
try:
- props, dummy = parsePropsFromForm(self.db, cl, self.form)
+ props = parsePropsFromForm(self.db, cl, self.form)
+ props['roles'] = self.instance.NEW_WEB_USER_ROLES
uid = cl.create(**props)
+ self.db.commit()
except ValueError, message:
action = self.form['__destination_url'].value
self.login(message, action=action)
return 0
+
+ # log the new user in
self.user = cl.get(uid, 'username')
+ # re-open the database for real, using the user
+ self.opendb(self.user)
password = cl.get(uid, 'password')
- self.set_cookie(self.user, self.form['password'].value)
+ self.set_cookie(self.user, password)
return 1
def set_cookie(self, user, password):
- # construct the cookie
- user = binascii.b2a_base64('%s:%s'%(user, password)).strip()
- if user[-1] == '=':
- if user[-2] == '=':
- user = user[:-2]
- else:
- user = user[:-1]
+ # TODO generate a much, much stronger session key ;)
+ self.session = binascii.b2a_base64(repr(time.time())).strip()
+
+ # clean up the base64
+ if self.session[-1] == '=':
+ if self.session[-2] == '=':
+ self.session = self.session[:-2]
+ else:
+ self.session = self.session[:-1]
+
+ # insert the session in the sessiondb
+ self.db.sessions.set(self.session, user=user, last_use=time.time())
+
+ # and commit immediately
+ self.db.sessions.commit()
+
+ # expire us in a long, long time
expire = Cookie._getdate(86400*365)
- path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
- self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;' % (
- user, expire, path)})
+
+ # generate the cookie path - make sure it has a trailing '/'
+ path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
+ ''))
+ self.header({'Set-Cookie': 'roundup_user=%s; expires=%s; Path=%s;'%(
+ self.session, expire, path)})
def make_user_anonymous(self):
- # make us anonymous if we can
- try:
- self.db.user.lookup('anonymous')
- self.user = 'anonymous'
- except KeyError:
- self.user = None
+ ''' Make us anonymous
+
+ This method used to handle non-existence of the 'anonymous'
+ user, but that user is mandatory now.
+ '''
+ self.db.user.lookup('anonymous')
+ self.user = 'anonymous'
def logout(self, message=None):
+ ''' Make us really anonymous - nuke the cookie too
+ '''
self.make_user_anonymous()
+
# construct the logout cookie
now = Cookie._getdate()
- path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME']))
+ path = '/'.join((self.env['SCRIPT_NAME'], self.env['INSTANCE_NAME'],
+ ''))
self.header({'Set-Cookie':
'roundup_user=deleted; Max-Age=0; expires=%s; Path=%s;'%(now,
path)})
self.login()
+ def opendb(self, user):
+ ''' Open the database.
+ '''
+ # open the db if the user has changed
+ if not hasattr(self, 'db') or user != self.db.journaltag:
+ self.db = self.instance.open(user)
def main(self):
+ ''' Wrap the request and handle unauthorised requests
+ '''
+ self.desired_action = None
+ try:
+ self.main_action()
+ except Unauthorised, message:
+ self.unauthorised(message)
+
+ def main_action(self):
'''Wrap the database accesses so we can close the database cleanly
'''
# determine the uid to use
- self.db = self.instance.open('admin')
+ self.opendb('admin')
+
+ # make sure we have the session Class
+ sessions = self.db.sessions
+
+ # age sessions, remove when they haven't been used for a week
+ # TODO: this shouldn't be done every access
+ week = 60*60*24*7
+ now = time.time()
+ for sessid in sessions.list():
+ interval = now - sessions.get(sessid, 'last_use')
+ if interval > week:
+ sessions.destroy(sessid)
+
+ # look up the user session cookie
cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', ''))
user = 'anonymous'
+
if (cookie.has_key('roundup_user') and
cookie['roundup_user'].value != 'deleted'):
- cookie = cookie['roundup_user'].value
- if len(cookie)%4:
- cookie = cookie + '='*(4-len(cookie)%4)
- try:
- user, password = binascii.a2b_base64(cookie).split(':')
- except (TypeError, binascii.Error, binascii.Incomplete):
- # damaged cookie!
- user, password = 'anonymous', ''
- # make sure the user exists
+ # get the session key from the cookie
+ self.session = cookie['roundup_user'].value
+ # get the user from the session
try:
- uid = self.db.user.lookup(user)
- # now validate the password
- if password != self.db.user.get(uid, 'password'):
- user = 'anonymous'
+ # update the lifetime datestamp
+ sessions.set(self.session, last_use=time.time())
+ sessions.commit()
+ user = sessions.get(self.session, 'user')
except KeyError:
user = 'anonymous'
+ # sanity check on the user still being valid
+ try:
+ self.db.user.lookup(user)
+ except (KeyError, TypeError):
+ user = 'anonymous'
+
# make sure the anonymous user is valid if we're using it
if user == 'anonymous':
self.make_user_anonymous()
else:
self.user = user
- # re-open the database for real, using the user
- self.db = self.instance.open(self.user)
-
# now figure which function to call
path = self.split_path
+ self.xtrapath = None
# default action to index if the path has no information in it
if not path or path[0] in ('', 'index'):
action = 'index'
else:
action = path[0]
-
- # Everthing ignores path[1:]
- # - The file download link generator actually relies on this - it
- # appends the name of the file to the URL so the download file name
- # is correct, but doesn't actually use it.
+ if len(path) > 1:
+ self.xtrapath = path[1:]
+ self.desired_action = action
# everyone is allowed to try to log in
if action == 'login_action':
return
# figure the resulting page
action = self.form['__destination_url'].value
- if not action:
- action = 'index'
- self.do_action(action)
- return
# allow anonymous people to register
- if action == 'newuser_action':
- # if we don't have a login and anonymous people aren't allowed to
- # register, then spit up the login form
- if self.ANONYMOUS_REGISTER == 'deny' and self.user is None:
- if action == 'login':
- self.login() # go to the index after login
- else:
- self.login(action=action)
- return
+ elif action == 'newuser_action':
# try to add the user
if not self.newuser_action():
return
# figure the resulting page
action = self.form['__destination_url'].value
- if not action:
- action = 'index'
- # no login or registration, make sure totally anonymous access is OK
- elif self.ANONYMOUS_ACCESS == 'deny' and self.user is None:
- if action == 'login':
- self.login() # go to the index after login
- else:
- self.login(action=action)
- return
+ # ok, now we have figured out who the user is, make sure the user
+ # has permission to use this interface
+ userid = self.db.user.lookup(self.user)
+ if not self.db.security.hasPermission('Web Access', userid):
+ raise Unauthorised, \
+ _("You do not have permission to access this interface.")
- # just a regular action
- self.do_action(action)
+ # re-open the database for real, using the user
+ self.opendb(self.user)
- # commit all changes to the database
- self.db.commit()
+ # make sure we have a sane action
+ if not action:
+ action = 'index'
+
+ # just a regular action
+ try:
+ self.do_action(action)
+ except Unauthorised, message:
+ # if unauth is raised here, then a page header will have
+ # been displayed
+ self.write('<p class="system-msg">%s</p>'%message)
+ else:
+ # commit all changes to the database
+ self.db.commit()
def do_action(self, action, dre=re.compile(r'([^\d]+)(\d+)'),
- nre=re.compile(r'new(\w+)')):
+ nre=re.compile(r'new(\w+)'), sre=re.compile(r'search(\w+)')):
'''Figure the user's action and do it.
'''
# here be the "normal" functionality
if action == 'list_classes':
self.classes()
return
+ if action == 'classhelp':
+ self.classhelp()
+ return
if action == 'login':
self.login()
return
if action == 'logout':
self.logout()
return
+ if action == 'remove':
+ self.remove()
+ return
+
+ # see if we're to display an existing node
m = dre.match(action)
if m:
self.classname = m.group(1)
try:
cl = self.db.classes[self.classname]
except KeyError:
- raise NotFound
+ raise NotFound, self.classname
try:
cl.get(self.nodeid, 'id')
except IndexError:
- raise NotFound
+ raise NotFound, self.nodeid
try:
func = getattr(self, 'show%s'%self.classname)
except AttributeError:
- raise NotFound
+ raise NotFound, 'show%s'%self.classname
func()
return
+
+ # see if we're to put up the new node page
m = nre.match(action)
if m:
self.classname = m.group(1)
try:
func = getattr(self, 'new%s'%self.classname)
+ except AttributeError:
+ raise NotFound, 'new%s'%self.classname
+ func()
+ return
+
+ # see if we're to put up the new node page
+ m = sre.match(action)
+ if m:
+ self.classname = m.group(1)
+ try:
+ func = getattr(self, 'search%s'%self.classname)
except AttributeError:
raise NotFound
func()
return
+
+ # otherwise, display the named class
self.classname = action
try:
self.db.getclass(self.classname)
except KeyError:
- raise NotFound
+ raise NotFound, self.classname
self.list()
+ def remove(self, dre=re.compile(r'([^\d]+)(\d+)')):
+ target = self.index_arg(':target')[0]
+ m = dre.match(target)
+ if m:
+ classname = m.group(1)
+ nodeid = m.group(2)
+ cl = self.db.getclass(classname)
+ cl.retire(nodeid)
+ # now take care of the reference
+ parentref = self.index_arg(':multilink')[0]
+ parent, prop = parentref.split(':')
+ m = dre.match(parent)
+ if m:
+ self.classname = m.group(1)
+ self.nodeid = m.group(2)
+ cl = self.db.getclass(self.classname)
+ value = cl.get(self.nodeid, prop)
+ value.remove(nodeid)
+ cl.set(self.nodeid, **{prop:value})
+ func = getattr(self, 'show%s'%self.classname)
+ return func()
+ else:
+ raise NotFound, parent
+ else:
+ raise NotFound, target
class ExtendedClient(Client):
'''Includes pages and page heading information that relate to the
showtimelog = Client.shownode
newsupport = Client.newnode
newtimelog = Client.newnode
+ searchsupport = Client.searchnode
default_index_sort = ['-activity']
default_index_group = ['priority']
default_index_filter = ['status']
default_index_columns = ['activity','status','title','assignedto']
default_index_filterspec = {'status': ['1', '2', '3', '4', '5', '6', '7']}
+ default_pagesize = '50'
- def pagehead(self, title, message=None):
- url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
- machine = self.env['SERVER_NAME']
- port = self.env['SERVER_PORT']
- if port != '80': machine = machine + ':' + port
- base = urlparse.urlunparse(('http', machine, url, None, None, None))
- if message is not None:
- message = _('<div class="system-msg">%(message)s</div>')%locals()
- else:
- message = ''
- style = open(os.path.join(self.TEMPLATES, 'style.css')).read()
- user_name = self.user or ''
- if self.user == 'admin':
- admin_links = _(' | <a href="list_classes">Class List</a>' \
- ' | <a href="user">User List</a>')
- else:
- admin_links = ''
- if self.user not in (None, 'anonymous'):
- userid = self.db.user.lookup(self.user)
- user_info = _('''
-<a href="issue?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=priority&show_customization=1">My Issues</a> |
-<a href="support?assignedto=%(userid)s&status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:filter=status,assignedto&:sort=-activity&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">My Support</a> |
-<a href="user%(userid)s">My Details</a> | <a href="logout">Logout</a>
-''')%locals()
- else:
- user_info = _('<a href="login">Login</a>')
- if self.user is not None:
- if self.user == 'admin':
- add_links = _('''
-| Add
-<a href="newissue">Issue</a>,
-<a href="newsupport">Support</a>,
-<a href="newuser">User</a>
-''')
- else:
- add_links = _('''
-| Add
-<a href="newissue">Issue</a>,
-<a href="newsupport">Support</a>,
-''')
- else:
- add_links = ''
- self.write(_('''<html><head>
-<title>%(title)s</title>
-<style type="text/css">%(style)s</style>
-</head>
-<body bgcolor=#ffffff>
-%(message)s
-<table width=100%% border=0 cellspacing=0 cellpadding=2>
-<tr class="location-bar"><td><big><strong>%(title)s</strong></big></td>
-<td align=right valign=bottom>%(user_name)s</td></tr>
-<tr class="location-bar">
-<td align=left>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>,
-<a href="support?status=-1,unread,deferred,chatting,need-eg,in-progress,testing,done-cbb&:sort=activity&:filter=status&:columns=id,activity,status,title,assignedto&:group=customername&show_customization=1">Support</a>
-| 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>,
-<a href="support?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=customername&show_customization=1">Support</a>
-%(add_links)s
-%(admin_links)s</td>
-<td align=right>%(user_info)s</td>
-</table>
-''')%locals())
-
-def parsePropsFromForm(db, cl, form, nodeid=0):
+def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')):
'''Pull properties for the given class out of the form.
'''
props = {}
- changed = {}
keys = form.keys()
- num_re = re.compile('^\d+$')
for key in keys:
if not cl.properties.has_key(key):
continue
elif isinstance(proptype, hyperdb.Password):
value = password.Password(form[key].value.strip())
elif isinstance(proptype, hyperdb.Date):
- value = date.Date(form[key].value.strip())
+ value = form[key].value.strip()
+ if value:
+ value = date.Date(form[key].value.strip())
+ else:
+ value = None
elif isinstance(proptype, hyperdb.Interval):
- value = date.Interval(form[key].value.strip())
+ value = form[key].value.strip()
+ if value:
+ value = date.Interval(form[key].value.strip())
+ else:
+ value = None
elif isinstance(proptype, hyperdb.Link):
value = form[key].value.strip()
# see if it's the "no selection" choice
if value == '-1':
- # don't set this property
- continue
+ value = None
else:
# handle key values
link = cl.properties[key].classname
'value': value, 'classname': link}
elif isinstance(proptype, hyperdb.Multilink):
value = form[key]
- if type(value) != type([]):
- value = [i.strip() for i in value.value.split(',')]
+ if hasattr(value, 'value'):
+ # Quite likely to be a FormItem instance
+ value = value.value
+ if not isinstance(value, type([])):
+ value = [i.strip() for i in value.split(',')]
else:
- value = [i.value.strip() for i in value]
+ value = [i.strip() for i in value]
link = cl.properties[key].classname
l = []
for entry in map(str, value):
l.append(entry)
l.sort()
value = l
- props[key] = value
+ elif isinstance(proptype, hyperdb.Boolean):
+ value = form[key].value.strip()
+ props[key] = value = value.lower() in ('yes', 'true', 'on', '1')
+ elif isinstance(proptype, hyperdb.Number):
+ value = form[key].value.strip()
+ props[key] = value = int(value)
# get the old value
if nodeid:
# value
if not cl.properties.has_key(key): raise
- # if changed, set it
- if nodeid and value != existing:
- changed[key] = value
+ # if changed, set it
+ if value != existing:
+ props[key] = value
+ else:
props[key] = value
- return props, changed
+ return props
#
# $Log: not supported by cvs2svn $
+# Revision 1.161 2002/08/19 00:21:10 richard
+# removed debug prints
+#
+# Revision 1.160 2002/08/19 00:20:34 richard
+# grant web access to admin ;)
+#
+# Revision 1.159 2002/08/16 04:29:41 richard
+# bugfix
+#
+# Revision 1.158 2002/08/15 00:40:10 richard
+# cleanup
+#
+# Revision 1.157 2002/08/13 20:16:09 gmcm
+# Use a real parser for templates.
+# Rewrite htmltemplate to use the parser (hack, hack).
+# Move the "do_XXX" methods to template_funcs.py.
+# Redo the funcion tests (but not Template tests - they're hopeless).
+# Simplified query form in cgi_client.
+# Ability to delete msgs, files, queries.
+# Ability to edit the metadata on files.
+#
+# Revision 1.156 2002/08/01 15:06:06 gmcm
+# Use same regex to split search terms as used to index text.
+# Fix to back_metakit for not changing journaltag on reopen.
+# Fix htmltemplate's do_link so [No <whatever>] strings are href'd.
+# Fix bogus "nosy edited ok" msg - the **d syntax does NOT share d between caller and callee.
+#
+# Revision 1.155 2002/08/01 00:56:22 richard
+# Added the web access and email access permissions, so people can restrict
+# access to users who register through the email interface (for example).
+# Also added "security" command to the roundup-admin interface to display the
+# Role/Permission config for an instance.
+#
+# Revision 1.154 2002/07/31 23:57:36 richard
+# . web forms may now unset Link values (like assignedto)
+#
+# Revision 1.153 2002/07/31 22:40:50 gmcm
+# Fixes to the search form and saving queries.
+# Fixes to sorting in back_metakit.py.
+#
+# Revision 1.152 2002/07/31 22:04:14 richard
+# cleanup
+#
+# Revision 1.151 2002/07/30 21:37:43 richard
+# oops, thanks Duncan Booth for spotting this one
+#
+# Revision 1.150 2002/07/30 20:43:18 gmcm
+# Oops, fix the permission check!
+#
+# Revision 1.149 2002/07/30 20:04:38 gmcm
+# Adapt metakit backend to new security scheme.
+# Put some more permission checks in cgi_client.
+#
+# Revision 1.148 2002/07/30 16:09:11 gmcm
+# Simple optimization.
+#
+# Revision 1.147 2002/07/30 08:22:38 richard
+# Session storage in the hyperdb was horribly, horribly inefficient. We use
+# a simple anydbm wrapper now - which could be overridden by the metakit
+# backend or RDB backend if necessary.
+# Much, much better.
+#
+# Revision 1.146 2002/07/30 05:27:30 richard
+# nicer error messages, and a bugfix
+#
+# Revision 1.145 2002/07/26 08:26:59 richard
+# Very close now. The cgi and mailgw now use the new security API. The two
+# templates have been migrated to that setup. Lots of unit tests. Still some
+# issue in the web form for editing Roles assigned to users.
+#
+# Revision 1.144 2002/07/25 07:14:05 richard
+# Bugger it. Here's the current shape of the new security implementation.
+# Still to do:
+# . call the security funcs from cgi and mailgw
+# . change shipped templates to include correct initialisation and remove
+# the old config vars
+# ... that seems like a lot. The bulk of the work has been done though. Honest :)
+#
+# Revision 1.143 2002/07/20 19:29:10 gmcm
+# Fixes/improvements to the search form & saved queries.
+#
+# Revision 1.142 2002/07/18 11:17:30 gmcm
+# Add Number and Boolean types to hyperdb.
+# Add conversion cases to web, mail & admin interfaces.
+# Add storage/serialization cases to back_anydbm & back_metakit.
+#
+# Revision 1.141 2002/07/17 12:39:10 gmcm
+# Saving, running & editing queries.
+#
+# Revision 1.140 2002/07/14 23:17:15 richard
+# cleaned up structure
+#
+# Revision 1.139 2002/07/14 06:14:40 richard
+# Some more TODOs
+#
+# Revision 1.138 2002/07/14 04:03:13 richard
+# Implemented a switch to disable journalling for a Class. CGI session
+# database now uses it.
+#
+# Revision 1.137 2002/07/10 07:00:30 richard
+# removed debugging
+#
+# Revision 1.136 2002/07/10 06:51:08 richard
+# . #576241 ] MultiLink problems in parsePropsFromForm
+#
+# Revision 1.135 2002/07/10 00:22:34 richard
+# . switched to using a session-based web login
+#
+# Revision 1.134 2002/07/09 04:19:09 richard
+# Added reindex command to roundup-admin.
+# Fixed reindex on first access.
+# Also fixed reindexing of entries that change.
+#
+# Revision 1.133 2002/07/08 15:32:05 gmcm
+# Pagination of index pages.
+# New search form.
+#
+# Revision 1.132 2002/07/08 07:26:14 richard
+# ehem
+#
+# Revision 1.131 2002/07/08 06:53:57 richard
+# Not sure why the cgi_client had an indexer argument.
+#
+# Revision 1.130 2002/06/27 12:01:53 gmcm
+# If the form has a :multilink, put a back href in the pageheader (back to the linked-to node).
+# Some minor optimizations (only compile regexes once).
+#
+# Revision 1.129 2002/06/20 23:52:11 richard
+# Better handling of unauth attempt to edit stuff
+#
+# Revision 1.128 2002/06/12 21:28:25 gmcm
+# Allow form to set user-properties on a Fileclass.
+# Don't assume that a Fileclass is named "files".
+#
+# Revision 1.127 2002/06/11 06:38:24 richard
+# . #565996 ] The "Attach a File to this Issue" fails
+#
+# Revision 1.126 2002/05/29 01:16:17 richard
+# Sorry about this huge checkin! It's fixing a lot of related stuff in one go
+# though.
+#
+# . #541941 ] changing multilink properties by mail
+# . #526730 ] search for messages capability
+# . #505180 ] split MailGW.handle_Message
+# - also changed cgi client since it was duplicating the functionality
+# . build htmlbase if tests are run using CVS checkout (removed note from
+# installation.txt)
+# . don't create an empty message on email issue creation if the email is empty
+#
+# Revision 1.125 2002/05/25 07:16:24 rochecompaan
+# Merged search_indexing-branch with HEAD
+#
+# Revision 1.124 2002/05/24 02:09:24 richard
+# Nothing like a live demo to show up the bugs ;)
+#
+# Revision 1.123 2002/05/22 05:04:13 richard
+# Oops
+#
+# Revision 1.122 2002/05/22 04:12:05 richard
+# . applied patch #558876 ] cgi client customization
+# ... with significant additions and modifications ;)
+# - extended handling of ML assignedto to all places it's handled
+# - added more NotFound info
+#
+# Revision 1.121 2002/05/21 06:08:10 richard
+# Handle migration
+#
+# Revision 1.120 2002/05/21 06:05:53 richard
+# . #551483 ] assignedto in Client.make_index_link
+#
+# Revision 1.119 2002/05/15 06:21:21 richard
+# . node caching now works, and gives a small boost in performance
+#
+# As a part of this, I cleaned up the DEBUG output and implemented TRACE
+# output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
+# CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
+# (using if __debug__ which is compiled out with -O)
+#
+# Revision 1.118 2002/05/12 23:46:33 richard
+# ehem, part 2
+#
+# Revision 1.117 2002/05/12 23:42:29 richard
+# ehem
+#
+# Revision 1.116 2002/05/02 08:07:49 richard
+# Added the ADD_AUTHOR_TO_NOSY handling to the CGI interface.
+#
+# Revision 1.115 2002/04/02 01:56:10 richard
+# . stop sending blank (whitespace-only) notes
+#
+# Revision 1.114.2.4 2002/05/02 11:49:18 rochecompaan
+# Allow customization of the search filters that should be displayed
+# on the search page.
+#
+# Revision 1.114.2.3 2002/04/20 13:23:31 rochecompaan
+# We now have a separate search page for nodes. Search links for
+# different classes can be customized in instance_config similar to
+# index links.
+#
+# Revision 1.114.2.2 2002/04/19 19:54:42 rochecompaan
+# cgi_client.py
+# removed search link for the time being
+# moved rendering of matches to htmltemplate
+# hyperdb.py
+# filtering of nodes on full text search incorporated in filter method
+# roundupdb.py
+# added paramater to call of filter method
+# roundup_indexer.py
+# added search method to RoundupIndexer class
+#
+# Revision 1.114.2.1 2002/04/03 11:55:57 rochecompaan
+# . Added feature #526730 - search for messages capability
+#
+# Revision 1.114 2002/03/17 23:06:05 richard
+# oops
+#
+# Revision 1.113 2002/03/14 23:59:24 richard
+# . #517734 ] web header customisation is obscure
+#
+# Revision 1.112 2002/03/12 22:52:26 richard
+# more pychecker warnings removed
+#
+# Revision 1.111 2002/02/25 04:32:21 richard
+# ahem
+#
+# Revision 1.110 2002/02/21 07:19:08 richard
+# ... and label, width and height control for extra flavour!
+#
+# Revision 1.109 2002/02/21 07:08:19 richard
+# oops
+#
+# Revision 1.108 2002/02/21 07:02:54 richard
+# The correct var is "HTTP_HOST"
+#
+# Revision 1.107 2002/02/21 06:57:38 richard
+# . Added popup help for classes using the classhelp html template function.
+# - add <display call="classhelp('priority', 'id,name,description')">
+# to an item page, and it generates a link to a popup window which displays
+# the id, name and description for the priority class. The description
+# field won't exist in most installations, but it will be added to the
+# default templates.
+#
+# Revision 1.106 2002/02/21 06:23:00 richard
+# *** empty log message ***
+#
+# Revision 1.105 2002/02/20 05:52:10 richard
+# better error handling
+#
+# Revision 1.104 2002/02/20 05:45:17 richard
+# Use the csv module for generating the form entry so it's correct.
+# [also noted the sf.net feature request id in the change log]
+#
+# Revision 1.103 2002/02/20 05:05:28 richard
+# . Added simple editing for classes that don't define a templated interface.
+# - access using the admin "class list" interface
+# - limited to admin-only
+# - requires the csv module from object-craft (url given if it's missing)
+#
+# Revision 1.102 2002/02/15 07:08:44 richard
+# . Alternate email addresses are now available for users. See the MIGRATION
+# file for info on how to activate the feature.
+#
+# Revision 1.101 2002/02/14 23:39:18 richard
+# . All forms now have "double-submit" protection when Javascript is enabled
+# on the client-side.
+#
+# Revision 1.100 2002/01/16 07:02:57 richard
+# . lots of date/interval related changes:
+# - more relaxed date format for input
+#
+# Revision 1.99 2002/01/16 03:02:42 richard
+# #503793 ] changing assignedto resets nosy list
+#
+# Revision 1.98 2002/01/14 02:20:14 richard
+# . changed all config accesses so they access either the instance or the
+# config attriubute on the db. This means that all config is obtained from
+# instance_config instead of the mish-mash of classes. This will make
+# switching to a ConfigParser setup easier too, I hope.
+#
+# At a minimum, this makes migration a _little_ easier (a lot easier in the
+# 0.5.0 switch, I hope!)
+#
+# Revision 1.97 2002/01/11 23:22:29 richard
+# . #502437 ] rogue reactor and unittest
+# in short, the nosy reactor was modifying the nosy list. That code had
+# been there for a long time, and I suspsect it was there because we
+# weren't generating the nosy list correctly in other places of the code.
+# We're now doing that, so the nosy-modifying code can go away from the
+# nosy reactor.
+#
+# Revision 1.96 2002/01/10 05:26:10 richard
+# missed a parsePropsFromForm in last update
+#
+# Revision 1.95 2002/01/10 03:39:45 richard
+# . fixed some problems with web editing and change detection
+#
+# Revision 1.94 2002/01/09 13:54:21 grubert
+# _add_assignedto_to_nosy did set nosy to assignedto only, no adding.
+#
+# Revision 1.93 2002/01/08 11:57:12 richard
+# crying out for real configuration handling... :(
+#
+# Revision 1.92 2002/01/08 04:12:05 richard
+# Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
+#
+# Revision 1.91 2002/01/08 04:03:47 richard
+# I mucked the intent of the code up.
+#
+# Revision 1.90 2002/01/08 03:56:55 richard
+# Oops, missed this before the beta:
+# . #495392 ] empty nosy -patch
+#
+# Revision 1.89 2002/01/07 20:24:45 richard
+# *mutter* stupid cutnpaste
+#
+# Revision 1.88 2002/01/02 02:31:38 richard
+# Sorry for the huge checkin message - I was only intending to implement #496356
+# but I found a number of places where things had been broken by transactions:
+# . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
+# for _all_ roundup-generated smtp messages to be sent to.
+# . the transaction cache had broken the roundupdb.Class set() reactors
+# . newly-created author users in the mailgw weren't being committed to the db
+#
+# Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
+# on when I found that stuff :):
+# . #496356 ] Use threading in messages
+# . detectors were being registered multiple times
+# . added tests for mailgw
+# . much better attaching of erroneous messages in the mail gateway
+#
+# Revision 1.87 2001/12/23 23:18:49 richard
+# We already had an admin-specific section of the web heading, no need to add
+# another one :)
+#
+# Revision 1.86 2001/12/20 15:43:01 rochecompaan
+# Features added:
+# . Multilink properties are now displayed as comma separated values in
+# a textbox
+# . The add user link is now only visible to the admin user
+# . Modified the mail gateway to reject submissions from unknown
+# addresses if ANONYMOUS_ACCESS is denied
+#
# Revision 1.85 2001/12/20 06:13:24 rochecompaan
# Bugs fixed:
# . Exception handling in hyperdb for strings-that-look-like numbers got