summary | shortlog | log | commit | commitdiff | tree
raw | patch | inline | side by side (parent: bf396e0)
raw | patch | inline | side by side (parent: bf396e0)
author | jlgijsbers <jlgijsbers@57a73879-2fb5-44c3-a270-3262357dd7e2> | |
Wed, 11 Feb 2004 21:34:31 +0000 (21:34 +0000) | ||
committer | jlgijsbers <jlgijsbers@57a73879-2fb5-44c3-a270-3262357dd7e2> | |
Wed, 11 Feb 2004 21:34:31 +0000 (21:34 +0000) |
* actions.py - the xxxAction and xxxPermission functions refactored into Action classes
* exceptions.py - all exceptions
* form_parser.py - parsePropsFromForm & extractFormList in a FormParser class
Also added some new tests for the Actions.
git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@2072 57a73879-2fb5-44c3-a270-3262357dd7e2
* exceptions.py - all exceptions
* form_parser.py - parsePropsFromForm & extractFormList in a FormParser class
Also added some new tests for the Actions.
git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@2072 57a73879-2fb5-44c3-a270-3262357dd7e2
roundup/cgi/actions.py | [new file with mode: 0755] | patch | blob |
roundup/cgi/client.py | patch | blob | history | |
roundup/cgi/exceptions.py | [new file with mode: 0755] | patch | blob |
roundup/cgi/form_parser.py | [new file with mode: 0755] | patch | blob |
test/test_actions.py | [new file with mode: 0755] | patch | blob |
test/test_cgi.py | patch | blob | history |
diff --git a/roundup/cgi/actions.py b/roundup/cgi/actions.py
--- /dev/null
+++ b/roundup/cgi/actions.py
@@ -0,0 +1,745 @@
+import re, cgi, StringIO, urllib, Cookie, time, random
+
+from roundup import hyperdb, token, date, password, rcsv
+from roundup.i18n import _
+from roundup.cgi import templating
+from roundup.cgi.exceptions import Redirect, Unauthorised
+from roundup.mailgw import uidFromAddress
+
+__all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
+ 'EditCSVAction', 'EditItemAction', 'PassResetAction',
+ 'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction']
+
+# used by a couple of routines
+chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+
+class Action:
+ def __init__(self, client):
+ self.client = client
+ self.form = client.form
+ self.db = client.db
+ self.nodeid = client.nodeid
+ self.template = client.template
+ self.classname = client.classname
+ self.userid = client.userid
+ self.base = client.base
+ self.user = client.user
+
+ def handle(self):
+ """Execute the action specified by this object."""
+ raise NotImplementedError
+
+ def permission(self):
+ """Check whether the user has permission to execute this action.
+
+ True by default.
+ """
+ return 1
+
+class ShowAction(Action):
+ def handle(self, typere=re.compile('[@:]type'),
+ numre=re.compile('[@:]number')):
+ """Show a node of a particular class/id."""
+ t = n = ''
+ for key in self.form.keys():
+ if typere.match(key):
+ t = self.form[key].value.strip()
+ elif numre.match(key):
+ n = self.form[key].value.strip()
+ if not t:
+ raise ValueError, 'Invalid %s number'%t
+ url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
+ raise Redirect, url
+
+class RetireAction(Action):
+ def handle(self):
+ """Retire the context item."""
+ # if we want to view the index template now, then unset the nodeid
+ # context info (a special-case for retire actions on the index page)
+ nodeid = self.nodeid
+ if self.template == 'index':
+ self.client.nodeid = None
+
+ # generic edit is per-class only
+ if not self.permission():
+ raise Unauthorised, _('You do not have permission to retire %s' %
+ self.classname)
+
+ # make sure we don't try to retire admin or anonymous
+ if self.classname == 'user' and \
+ self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
+ raise ValueError, _('You may not retire the admin or anonymous user')
+
+ # do the retire
+ self.db.getclass(self.classname).retire(nodeid)
+ self.db.commit()
+
+ self.client.ok_message.append(
+ _('%(classname)s %(itemid)s has been retired')%{
+ 'classname': self.classname.capitalize(), 'itemid': nodeid})
+
+ def permission(self):
+ """Determine whether the user has permission to retire this class.
+
+ Base behaviour is to check the user can edit this class.
+ """
+ return self.db.security.hasPermission('Edit', self.client.userid,
+ self.client.classname)
+
+class SearchAction(Action):
+ def handle(self, wcre=re.compile(r'[\s,]+')):
+ """Mangle some of the form variables.
+
+ Set the form ":filter" variable based on the values of the filter
+ variables - if they're set to anything other than "dontcare" then add
+ them to :filter.
+
+ Handle the ":queryname" variable and save off the query to the user's
+ query list.
+
+ Split any String query values on whitespace and comma.
+
+ """
+ # generic edit is per-class only
+ if not self.permission():
+ raise Unauthorised, _('You do not have permission to search %s' %
+ self.classname)
+
+ self.fakeFilterVars()
+ queryname = self.getQueryName()
+
+ # handle saving the query params
+ if queryname:
+ # parse the environment and figure what the query _is_
+ req = templating.HTMLRequest(self.client)
+
+ # The [1:] strips off the '?' character, it isn't part of the
+ # query string.
+ url = req.indexargs_href('', {})[1:]
+
+ # handle editing an existing query
+ try:
+ qid = self.db.query.lookup(queryname)
+ self.db.query.set(qid, klass=self.classname, url=url)
+ except KeyError:
+ # create a query
+ qid = self.db.query.create(name=queryname,
+ klass=self.classname, url=url)
+
+ # and add it to the user's query multilink
+ queries = self.db.user.get(self.userid, 'queries')
+ queries.append(qid)
+ self.db.user.set(self.userid, queries=queries)
+
+ # commit the query change to the database
+ self.db.commit()
+
+ def fakeFilterVars(self):
+ """Add a faked :filter form variable for each filtering prop."""
+ props = self.db.classes[self.classname].getprops()
+ for key in self.form.keys():
+ if not props.has_key(key):
+ continue
+ if isinstance(self.form[key], type([])):
+ # search for at least one entry which is not empty
+ for minifield in self.form[key]:
+ if minifield.value:
+ break
+ else:
+ continue
+ else:
+ if not self.form[key].value:
+ continue
+ if isinstance(props[key], hyperdb.String):
+ v = self.form[key].value
+ l = token.token_split(v)
+ if len(l) > 1 or l[0] != v:
+ self.form.value.remove(self.form[key])
+ # replace the single value with the split list
+ for v in l:
+ self.form.value.append(cgi.MiniFieldStorage(key, v))
+
+ self.form.value.append(cgi.MiniFieldStorage('@filter', key))
+
+ FV_QUERYNAME = re.compile(r'[@:]queryname')
+ def getQueryName(self):
+ for key in self.form.keys():
+ if self.FV_QUERYNAME.match(key):
+ return self.form[key].value.strip()
+ return ''
+
+ def permission(self):
+ return self.db.security.hasPermission('View', self.client.userid,
+ self.client.classname)
+
+class EditCSVAction(Action):
+ def handle(self):
+ """Performs an edit of all of a class' items in one go.
+
+ The "rows" CGI var defines the CSV-formatted entries for the class. New
+ nodes are identified by the ID 'X' (or any other non-existent ID) and
+ removed lines are retired.
+
+ """
+ # this is per-class only
+ if not self.permission():
+ self.client.error_message.append(
+ _('You do not have permission to edit %s' %self.classname))
+ return
+
+ # get the CSV module
+ if rcsv.error:
+ self.client.error_message.append(_(rcsv.error))
+ return
+
+ cl = self.db.classes[self.classname]
+ idlessprops = cl.getprops(protected=0).keys()
+ idlessprops.sort()
+ props = ['id'] + idlessprops
+
+ # do the edit
+ rows = StringIO.StringIO(self.form['rows'].value)
+ reader = rcsv.reader(rows, rcsv.comma_separated)
+ found = {}
+ line = 0
+ for values in reader:
+ line += 1
+ if line == 1: continue
+ # skip property names header
+ if values == props:
+ continue
+
+ # extract the nodeid
+ nodeid, values = values[0], values[1:]
+ found[nodeid] = 1
+
+ # see if the node exists
+ if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
+ exists = 0
+ else:
+ exists = 1
+
+ # confirm correct weight
+ if len(idlessprops) != len(values):
+ self.client.error_message.append(
+ _('Not enough values on line %(line)s')%{'line':line})
+ return
+
+ # extract the new values
+ d = {}
+ for name, value in zip(idlessprops, values):
+ prop = cl.properties[name]
+ value = value.strip()
+ # only add the property if it has a value
+ if value:
+ # if it's a multilink, split it
+ if isinstance(prop, hyperdb.Multilink):
+ value = value.split(':')
+ elif isinstance(prop, hyperdb.Password):
+ value = password.Password(value)
+ elif isinstance(prop, hyperdb.Interval):
+ value = date.Interval(value)
+ elif isinstance(prop, hyperdb.Date):
+ value = date.Date(value)
+ elif isinstance(prop, hyperdb.Boolean):
+ value = value.lower() in ('yes', 'true', 'on', '1')
+ elif isinstance(prop, hyperdb.Number):
+ value = float(value)
+ d[name] = value
+ elif exists:
+ # nuke the existing value
+ if isinstance(prop, hyperdb.Multilink):
+ d[name] = []
+ else:
+ d[name] = None
+
+ # perform the edit
+ if exists:
+ # 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)
+
+ # all OK
+ self.db.commit()
+
+ self.client.ok_message.append(_('Items edited OK'))
+
+ def permission(self):
+ return self.db.security.hasPermission('Edit', self.client.userid,
+ self.client.classname)
+
+class EditItemAction(Action):
+ def handle(self):
+ """Perform an edit of an item in the database.
+
+ See parsePropsFromForm and _editnodes for special variables.
+
+ """
+ props, links = self.client.parsePropsFromForm()
+
+ # handle the props
+ try:
+ message = self._editnodes(props, links)
+ except (ValueError, KeyError, IndexError), message:
+ self.client.error_message.append(_('Apply Error: ') + str(message))
+ return
+
+ # commit now that all the tricky stuff is done
+ self.db.commit()
+
+ # redirect to the item's edit page
+ raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
+ self.classname, self.client.nodeid,
+ urllib.quote(message),
+ urllib.quote(self.template))
+
+ def editItemPermission(self, props):
+ """Determine whether the user has permission to edit this item.
+
+ Base behaviour is to check the user can edit this class. If we're
+ editing the"user" class, users are allowed to edit their own details.
+ Unless it's the "roles" property, which requires the special Permission
+ "Web Roles".
+ """
+ # if this is a user node and the user is editing their own node, then
+ # we're OK
+ has = self.db.security.hasPermission
+ if self.classname == 'user':
+ # reject if someone's trying to edit "roles" and doesn't have the
+ # right permission.
+ if props.has_key('roles') and not has('Web Roles', self.userid,
+ 'user'):
+ return 0
+ # if the item being edited is the current user, we're ok
+ if (self.nodeid == self.userid
+ and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
+ return 1
+ if self.db.security.hasPermission('Edit', self.userid, self.classname):
+ return 1
+ return 0
+
+ def newItemPermission(self, props):
+ """Determine whether the user has permission to create (edit) this item.
+
+ Base behaviour is to check the user can edit this class. No additional
+ property checks are made. Additionally, new user items may be created
+ if the user has the "Web Registration" Permission.
+
+ """
+ has = self.db.security.hasPermission
+ if self.classname == 'user' and has('Web Registration', self.userid,
+ 'user'):
+ return 1
+ if has('Edit', self.userid, self.classname):
+ return 1
+ return 0
+
+ #
+ # Utility methods for editing
+ #
+ def _editnodes(self, all_props, all_links, newids=None):
+ ''' Use the props in all_props to perform edit and creation, then
+ use the link specs in all_links to do linking.
+ '''
+ # figure dependencies and re-work links
+ deps = {}
+ links = {}
+ for cn, nodeid, propname, vlist in all_links:
+ if not all_props.has_key((cn, nodeid)):
+ # link item to link to doesn't (and won't) exist
+ continue
+ for value in vlist:
+ if not all_props.has_key(value):
+ # link item to link to doesn't (and won't) exist
+ continue
+ deps.setdefault((cn, nodeid), []).append(value)
+ links.setdefault(value, []).append((cn, nodeid, propname))
+
+ # figure chained dependencies ordering
+ order = []
+ done = {}
+ # loop detection
+ change = 0
+ while len(all_props) != len(done):
+ for needed in all_props.keys():
+ if done.has_key(needed):
+ continue
+ tlist = deps.get(needed, [])
+ for target in tlist:
+ if not done.has_key(target):
+ break
+ else:
+ done[needed] = 1
+ order.append(needed)
+ change = 1
+ if not change:
+ raise ValueError, 'linking must not loop!'
+
+ # now, edit / create
+ m = []
+ for needed in order:
+ props = all_props[needed]
+ if not props:
+ # nothing to do
+ continue
+ cn, nodeid = needed
+
+ if nodeid is not None and int(nodeid) > 0:
+ # make changes to the node
+ props = self._changenode(cn, nodeid, props)
+
+ # and some nice feedback for the user
+ if props:
+ info = ', '.join(props.keys())
+ m.append('%s %s %s edited ok'%(cn, nodeid, info))
+ else:
+ m.append('%s %s - nothing changed'%(cn, nodeid))
+ else:
+ assert props
+
+ # make a new node
+ newid = self._createnode(cn, props)
+ if nodeid is None:
+ self.client.nodeid = newid
+ nodeid = newid
+
+ # and some nice feedback for the user
+ m.append('%s %s created'%(cn, newid))
+
+ # fill in new ids in links
+ if links.has_key(needed):
+ for linkcn, linkid, linkprop in links[needed]:
+ props = all_props[(linkcn, linkid)]
+ cl = self.db.classes[linkcn]
+ propdef = cl.getprops()[linkprop]
+ if not props.has_key(linkprop):
+ if linkid is None or linkid.startswith('-'):
+ # linking to a new item
+ if isinstance(propdef, hyperdb.Multilink):
+ props[linkprop] = [newid]
+ else:
+ props[linkprop] = newid
+ else:
+ # linking to an existing item
+ if isinstance(propdef, hyperdb.Multilink):
+ existing = cl.get(linkid, linkprop)[:]
+ existing.append(nodeid)
+ props[linkprop] = existing
+ else:
+ props[linkprop] = newid
+
+ return '<br>'.join(m)
+
+ def _changenode(self, cn, nodeid, props):
+ """Change the node based on the contents of the form."""
+ # check for permission
+ if not self.editItemPermission(props):
+ raise Unauthorised, 'You do not have permission to edit %s'%cn
+
+ # make the changes
+ cl = self.db.classes[cn]
+ return cl.set(nodeid, **props)
+
+ def _createnode(self, cn, props):
+ """Create a node based on the contents of the form."""
+ # check for permission
+ if not self.newItemPermission(props):
+ raise Unauthorised, 'You do not have permission to create %s'%cn
+
+ # create the node and return its id
+ cl = self.db.classes[cn]
+ return cl.create(**props)
+
+class PassResetAction(Action):
+ def handle(self):
+ """Handle password reset requests.
+
+ Presence of either "name" or "address" generates email. Presence of
+ "otk" performs the reset.
+
+ """
+ if self.form.has_key('otk'):
+ # pull the rego information out of the otk database
+ otk = self.form['otk'].value
+ uid = self.db.otks.get(otk, 'uid')
+ if uid is None:
+ self.client.error_message.append("""Invalid One Time Key!
+(a Mozilla bug may cause this message to show up erroneously,
+ please check your email)""")
+ return
+
+ # re-open the database as "admin"
+ if self.user != 'admin':
+ self.client.opendb('admin')
+ self.db = self.client.db
+
+ # change the password
+ newpw = password.generatePassword()
+
+ cl = self.db.user
+# XXX we need to make the "default" page be able to display errors!
+ try:
+ # set the password
+ cl.set(uid, password=password.Password(newpw))
+ # clear the props from the otk database
+ self.db.otks.destroy(otk)
+ self.db.commit()
+ except (ValueError, KeyError), message:
+ self.client.error_message.append(str(message))
+ return
+
+ # user info
+ address = self.db.user.get(uid, 'address')
+ name = self.db.user.get(uid, 'username')
+
+ # send the email
+ tracker_name = self.db.config.TRACKER_NAME
+ subject = 'Password reset for %s'%tracker_name
+ body = '''
+The password has been reset for username "%(name)s".
+
+Your password is now: %(password)s
+'''%{'name': name, 'password': newpw}
+ if not self.client.standard_message([address], subject, body):
+ return
+
+ self.client.ok_message.append('Password reset and email sent to %s' %
+ address)
+ return
+
+ # no OTK, so now figure the user
+ if self.form.has_key('username'):
+ name = self.form['username'].value
+ try:
+ uid = self.db.user.lookup(name)
+ except KeyError:
+ self.client.error_message.append('Unknown username')
+ return
+ address = self.db.user.get(uid, 'address')
+ elif self.form.has_key('address'):
+ address = self.form['address'].value
+ uid = uidFromAddress(self.db, ('', address), create=0)
+ if not uid:
+ self.client.error_message.append('Unknown email address')
+ return
+ name = self.db.user.get(uid, 'username')
+ else:
+ self.client.error_message.append('You need to specify a username '
+ 'or address')
+ return
+
+ # generate the one-time-key and store the props for later
+ otk = ''.join([random.choice(chars) for x in range(32)])
+ self.db.otks.set(otk, uid=uid, __time=time.time())
+
+ # send the email
+ tracker_name = self.db.config.TRACKER_NAME
+ subject = 'Confirm reset of password for %s'%tracker_name
+ body = '''
+Someone, perhaps you, has requested that the password be changed for your
+username, "%(name)s". If you wish to proceed with the change, please follow
+the link below:
+
+ %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
+
+You should then receive another email with the new password.
+'''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
+ if not self.client.standard_message([address], subject, body):
+ return
+
+ self.client.ok_message.append('Email sent to %s'%address)
+
+class ConfRegoAction(Action):
+ def handle(self):
+ """Grab the OTK, use it to load up the new user details."""
+ try:
+ # pull the rego information out of the otk database
+ self.userid = self.db.confirm_registration(self.form['otk'].value)
+ except (ValueError, KeyError), message:
+ # XXX: we need to make the "default" page be able to display errors!
+ self.client.error_message.append(str(message))
+ return
+
+ # log the new user in
+ self.client.user = self.db.user.get(self.userid, 'username')
+ # re-open the database for real, using the user
+ self.client.opendb(self.client.user)
+ self.db = client.db
+
+ # if we have a session, update it
+ if hasattr(self, 'session'):
+ self.db.sessions.set(self.session, user=self.user,
+ last_use=time.time())
+ else:
+ # new session cookie
+ self.client.set_cookie(self.user)
+
+ # nice message
+ message = _('You are now registered, welcome!')
+
+ # redirect to the user's page
+ raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
+ self.userid, urllib.quote(message))
+
+class RegisterAction(Action):
+ def handle(self):
+ """Attempt to create a new user based on the contents of the form
+ and then set the cookie.
+
+ Return 1 on successful login.
+ """
+ props = self.client.parsePropsFromForm()[0][('user', None)]
+
+ # make sure we're allowed to register
+ if not self.permission(props):
+ raise Unauthorised, _("You do not have permission to register")
+
+ try:
+ self.db.user.lookup(props['username'])
+ self.client.error_message.append('Error: A user with the username "%s" '
+ 'already exists'%props['username'])
+ return
+ except KeyError:
+ pass
+
+ # generate the one-time-key and store the props for later
+ otk = ''.join([random.choice(chars) for x in range(32)])
+ for propname, proptype in self.db.user.getprops().items():
+ value = props.get(propname, None)
+ if value is None:
+ pass
+ elif isinstance(proptype, hyperdb.Date):
+ props[propname] = str(value)
+ elif isinstance(proptype, hyperdb.Interval):
+ props[propname] = str(value)
+ elif isinstance(proptype, hyperdb.Password):
+ props[propname] = str(value)
+ props['__time'] = time.time()
+ self.db.otks.set(otk, **props)
+
+ # send the email
+ tracker_name = self.db.config.TRACKER_NAME
+ tracker_email = self.db.config.TRACKER_EMAIL
+ subject = 'Complete your registration to %s -- key %s' % (tracker_name,
+ otk)
+ body = """To complete your registration of the user "%(name)s" with
+%(tracker)s, please do one of the following:
+
+- send a reply to %(tracker_email)s and maintain the subject line as is (the
+reply's additional "Re:" is ok),
+
+- or visit the following URL:
+
+%(url)s?@action=confrego&otk=%(otk)s
+""" % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
+ 'otk': otk, 'tracker_email': tracker_email}
+ if not self.client.standard_message([props['address']], subject, body,
+ tracker_email):
+ return
+
+ # commit changes to the database
+ self.db.commit()
+
+ # redirect to the "you're almost there" page
+ raise Redirect, '%suser?@template=rego_progress'%self.base
+
+ def permission(self, props):
+ """Determine whether the user has permission to register
+
+ Base behaviour is to check the user has "Web Registration".
+
+ """
+ # registration isn't allowed to supply roles
+ if props.has_key('roles'):
+ return 0
+ if self.db.security.hasPermission('Web Registration', self.userid):
+ return 1
+ return 0
+
+class LogoutAction(Action):
+ def handle(self):
+ """Make us really anonymous - nuke the cookie too."""
+ # log us out
+ self.client.make_user_anonymous()
+
+ # construct the logout cookie
+ now = Cookie._getdate()
+ self.client.additional_headers['Set-Cookie'] = \
+ '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.client.cookie_name,
+ now, self.client.cookie_path)
+
+ # Let the user know what's going on
+ self.client.ok_message.append(_('You are logged out'))
+
+class LoginAction(Action):
+ def handle(self):
+ """Attempt to log a user in.
+
+ Sets up a session for the user which contains the login credentials.
+
+ """
+ # we need the username at a minimum
+ if not self.form.has_key('__login_name'):
+ self.client.error_message.append(_('Username required'))
+ return
+
+ # get the login info
+ self.client.user = self.form['__login_name'].value
+ if self.form.has_key('__login_password'):
+ password = self.form['__login_password'].value
+ else:
+ password = ''
+
+ # make sure the user exists
+ try:
+ self.client.userid = self.db.user.lookup(self.client.user)
+ except KeyError:
+ name = self.client.user
+ self.client.error_message.append(_('No such user "%(name)s"')%locals())
+ self.client.make_user_anonymous()
+ return
+
+ # verify the password
+ if not self.verifyPassword(self.client.userid, password):
+ self.client.make_user_anonymous()
+ self.client.error_message.append(_('Incorrect password'))
+ return
+
+ # make sure we're allowed to be here
+ if not self.permission():
+ self.client.make_user_anonymous()
+ self.client.error_message.append(_("You do not have permission to login"))
+ return
+
+ # now we're OK, re-open the database for real, using the user
+ self.client.opendb(self.client.user)
+
+ # set the session cookie
+ self.client.set_cookie(self.client.user)
+
+ def verifyPassword(self, userid, password):
+ ''' Verify the password that the user has supplied
+ '''
+ stored = self.db.user.get(self.client.userid, 'password')
+ if password == stored:
+ return 1
+ if not password and not stored:
+ return 1
+ return 0
+
+ def permission(self):
+ """Determine whether the user has permission to log in.
+
+ Base behaviour is to check the user has "Web Access".
+
+ """
+ if not self.db.security.hasPermission('Web Access', self.client.userid):
+ return 0
+ return 1
diff --git a/roundup/cgi/client.py b/roundup/cgi/client.py
index 672f89918b5b96777dced1e9581872d95411f96b..ace158e16abd92625c7a77f817ebb7987e8a11ef 100644 (file)
--- a/roundup/cgi/client.py
+++ b/roundup/cgi/client.py
-# $Id: client.py,v 1.154 2004-01-20 05:55:24 richard Exp $
+# $Id: client.py,v 1.155 2004-02-11 21:34:31 jlgijsbers Exp $
__doc__ = """
WWW request handler (also used in the stand-alone server).
"""
import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
-import binascii, Cookie, time, random, MimeWriter, smtplib, socket, quopri
-import stat, rfc822
+import binascii, Cookie, time, random, stat, rfc822
-from roundup import roundupdb, date, hyperdb, password, token, rcsv
+from roundup import roundupdb, date, hyperdb, password
from roundup.i18n import _
from roundup.cgi import templating, cgitb
-from roundup.cgi.PageTemplates import PageTemplate
-from roundup.rfc2822 import encode_header
-from roundup.mailgw import uidFromAddress
+from roundup.cgi.actions import *
+from roundup.cgi.exceptions import *
+from roundup.cgi.form_parser import FormParser
from roundup.mailer import Mailer, MessageSendError
-class HTTPException(Exception):
- pass
-class Unauthorised(HTTPException):
- pass
-class NotFound(HTTPException):
- pass
-class Redirect(HTTPException):
- pass
-class NotModified(HTTPException):
- pass
-
-# used by a couple of routines
-chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
-
-class FormError(ValueError):
- """ An "expected" exception occurred during form parsing.
- - ie. something we know can go wrong, and don't want to alarm the
- user with
-
- We trap this at the user interface level and feed back a nice error
- to the user.
- """
- pass
-
-class SendFile(Exception):
- ''' Send a file from the database '''
-
-class SendStaticFile(Exception):
- ''' Send a static file from the instance html directory '''
-
def initialiseSecurity(security):
''' Create some Permissions and Roles on the security object
FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
- FV_QUERYNAME = re.compile(r'[@:]queryname')
-
- # edit form variable handling (see unit tests)
- FV_LABELS = r'''
- ^(
- (?P<note>[@:]note)|
- (?P<file>[@:]file)|
- (
- ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
- ((?P<required>[@:]required$)| # :required
- (
- (
- (?P<add>[@:]add[@:])| # :add:<prop>
- (?P<remove>[@:]remove[@:])| # :remove:<prop>
- (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
- (?P<link>[@:]link[@:])| # :link:<prop>
- ([@:]) # just a separator
- )?
- (?P<propname>[^@:]+) # <prop>
- )
- )
- )
- )$'''
-
# Note: index page stuff doesn't appear here:
# columns, sort, sortdir, filter, group, groupdir, search_text,
# pagesize, startwith
# these are the actions that are available
actions = (
- ('edit', 'editItemAction'),
- ('editcsv', 'editCSVAction'),
- ('new', 'newItemAction'),
- ('register', 'registerAction'),
- ('confrego', 'confRegoAction'),
- ('passrst', 'passResetAction'),
- ('login', 'loginAction'),
- ('logout', 'logout_action'),
- ('search', 'searchAction'),
- ('retire', 'retireAction'),
- ('show', 'showAction'),
+ ('edit', EditItemAction),
+ ('editcsv', EditCSVAction),
+ ('new', EditItemAction),
+ ('register', RegisterAction),
+ ('confrego', ConfRegoAction),
+ ('passrst', PassResetAction),
+ ('login', LoginAction),
+ ('logout', LogoutAction),
+ ('search', SearchAction),
+ ('retire', RetireAction),
+ ('show', ShowAction),
)
def handle_action(self):
''' Determine whether there should be an Action called.
return None
try:
# get the action, validate it
- for name, method in self.actions:
+ for name, action_klass in self.actions:
if name == action:
break
else:
raise ValueError, 'No such action "%s"'%action
# call the mapped action
- getattr(self, method)()
- except Redirect:
- raise
- except Unauthorised:
- raise
+ action_klass(self).handle()
+ except ValueError, err:
+ self.error_message.append(str(err))
def write(self, content):
if not self.headers_done:
self.db.close()
self.db = self.instance.open(user)
- #
- # Actions
- #
- def loginAction(self):
- ''' Attempt to log a user in.
-
- Sets up a session for the user which contains the login
- credentials.
- '''
- # we need the username at a minimum
- if not self.form.has_key('__login_name'):
- self.error_message.append(_('Username required'))
- return
-
- # get the login info
- self.user = self.form['__login_name'].value
- if self.form.has_key('__login_password'):
- password = self.form['__login_password'].value
- else:
- password = ''
-
- # make sure the user exists
- try:
- self.userid = self.db.user.lookup(self.user)
- except KeyError:
- name = self.user
- self.error_message.append(_('No such user "%(name)s"')%locals())
- self.make_user_anonymous()
- return
-
- # verify the password
- if not self.verifyPassword(self.userid, password):
- self.make_user_anonymous()
- self.error_message.append(_('Incorrect password'))
- return
-
- # make sure we're allowed to be here
- if not self.loginPermission():
- self.make_user_anonymous()
- self.error_message.append(_("You do not have permission to login"))
- return
-
- # now we're OK, re-open the database for real, using the user
- self.opendb(self.user)
-
- # set the session cookie
- self.set_cookie(self.user)
-
- def verifyPassword(self, userid, password):
- ''' Verify the password that the user has supplied
- '''
- stored = self.db.user.get(self.userid, 'password')
- if password == stored:
- return 1
- if not password and not stored:
- return 1
- return 0
-
- def loginPermission(self):
- ''' Determine whether the user has permission to log in.
-
- Base behaviour is to check the user has "Web Access".
- '''
- if not self.db.security.hasPermission('Web Access', self.userid):
- return 0
- return 1
-
- def logout_action(self):
- ''' Make us really anonymous - nuke the cookie too
- '''
- # log us out
- self.make_user_anonymous()
-
- # construct the logout cookie
- now = Cookie._getdate()
- self.additional_headers['Set-Cookie'] = \
- '%s=deleted; Max-Age=0; expires=%s; Path=%s;'%(self.cookie_name,
- now, self.cookie_path)
-
- # Let the user know what's going on
- self.ok_message.append(_('You are logged out'))
-
- def registerAction(self):
- '''Attempt to create a new user based on the contents of the form
- and then set the cookie.
-
- return 1 on successful login
- '''
- props = self.parsePropsFromForm()[0][('user', None)]
-
- # make sure we're allowed to register
- if not self.registerPermission(props):
- raise Unauthorised, _("You do not have permission to register")
-
- try:
- self.db.user.lookup(props['username'])
- self.error_message.append('Error: A user with the username "%s" '
- 'already exists'%props['username'])
- return
- except KeyError:
- pass
-
- # generate the one-time-key and store the props for later
- otk = ''.join([random.choice(chars) for x in range(32)])
- for propname, proptype in self.db.user.getprops().items():
- value = props.get(propname, None)
- if value is None:
- pass
- elif isinstance(proptype, hyperdb.Date):
- props[propname] = str(value)
- elif isinstance(proptype, hyperdb.Interval):
- props[propname] = str(value)
- elif isinstance(proptype, hyperdb.Password):
- props[propname] = str(value)
- props['__time'] = time.time()
- self.db.otks.set(otk, **props)
-
- # send the email
- tracker_name = self.db.config.TRACKER_NAME
- tracker_email = self.db.config.TRACKER_EMAIL
- subject = 'Complete your registration to %s -- key %s' % (tracker_name,
- otk)
- body = """To complete your registration of the user "%(name)s" with
-%(tracker)s, please do one of the following:
-
-- send a reply to %(tracker_email)s and maintain the subject line as is (the
-reply's additional "Re:" is ok),
-
-- or visit the following URL:
-
- %(url)s?@action=confrego&otk=%(otk)s
-""" % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
- 'otk': otk, 'tracker_email': tracker_email}
- if not self.standard_message([props['address']], subject, body,
- tracker_email):
- return
-
- # commit changes to the database
- self.db.commit()
-
- # redirect to the "you're almost there" page
- raise Redirect, '%suser?@template=rego_progress'%self.base
-
def standard_message(self, to, subject, body, author=None):
try:
self.mailer.standard_message(to, subject, body, author)
return 1
except MessageSendError, e:
self.error_message.append(str(e))
-
- def registerPermission(self, props):
- ''' Determine whether the user has permission to register
-
- Base behaviour is to check the user has "Web Registration".
- '''
- # registration isn't allowed to supply roles
- if props.has_key('roles'):
- return 0
- if self.db.security.hasPermission('Web Registration', self.userid):
- return 1
- return 0
-
- def confRegoAction(self):
- ''' Grab the OTK, use it to load up the new user details
- '''
- try:
- # pull the rego information out of the otk database
- self.userid = self.db.confirm_registration(self.form['otk'].value)
- except (ValueError, KeyError), message:
- # XXX: we need to make the "default" page be able to display errors!
- self.error_message.append(str(message))
- return
-
- # log the new user in
- self.user = self.db.user.get(self.userid, 'username')
- # re-open the database for real, using the user
- self.opendb(self.user)
-
- # if we have a session, update it
- if hasattr(self, 'session'):
- self.db.sessions.set(self.session, user=self.user,
- last_use=time.time())
- else:
- # new session cookie
- self.set_cookie(self.user)
-
- # nice message
- message = _('You are now registered, welcome!')
-
- # redirect to the user's page
- raise Redirect, '%suser%s?@ok_message=%s'%(self.base,
- self.userid, urllib.quote(message))
-
- def passResetAction(self):
- ''' Handle password reset requests.
-
- Presence of either "name" or "address" generate email.
- Presense of "otk" performs the reset.
- '''
- if self.form.has_key('otk'):
- # pull the rego information out of the otk database
- otk = self.form['otk'].value
- uid = self.db.otks.get(otk, 'uid')
- if uid is None:
- self.error_message.append("""Invalid One Time Key!
-(a Mozilla bug may cause this message to show up erroneously,
- please check your email)""")
- return
-
- # re-open the database as "admin"
- if self.user != 'admin':
- self.opendb('admin')
-
- # change the password
- newpw = password.generatePassword()
-
- cl = self.db.user
-# XXX we need to make the "default" page be able to display errors!
- try:
- # set the password
- cl.set(uid, password=password.Password(newpw))
- # clear the props from the otk database
- self.db.otks.destroy(otk)
- self.db.commit()
- except (ValueError, KeyError), message:
- self.error_message.append(str(message))
- return
-
- # user info
- address = self.db.user.get(uid, 'address')
- name = self.db.user.get(uid, 'username')
-
- # send the email
- tracker_name = self.db.config.TRACKER_NAME
- subject = 'Password reset for %s'%tracker_name
- body = '''
-The password has been reset for username "%(name)s".
-
-Your password is now: %(password)s
-'''%{'name': name, 'password': newpw}
- if not self.standard_message([address], subject, body):
- return
-
- self.ok_message.append('Password reset and email sent to %s' %
- address)
- return
-
- # no OTK, so now figure the user
- if self.form.has_key('username'):
- name = self.form['username'].value
- try:
- uid = self.db.user.lookup(name)
- except KeyError:
- self.error_message.append('Unknown username')
- return
- address = self.db.user.get(uid, 'address')
- elif self.form.has_key('address'):
- address = self.form['address'].value
- uid = uidFromAddress(self.db, ('', address), create=0)
- if not uid:
- self.error_message.append('Unknown email address')
- return
- name = self.db.user.get(uid, 'username')
- else:
- self.error_message.append('You need to specify a username '
- 'or address')
- return
-
- # generate the one-time-key and store the props for later
- otk = ''.join([random.choice(chars) for x in range(32)])
- self.db.otks.set(otk, uid=uid, __time=time.time())
-
- # send the email
- tracker_name = self.db.config.TRACKER_NAME
- subject = 'Confirm reset of password for %s'%tracker_name
- body = '''
-Someone, perhaps you, has requested that the password be changed for your
-username, "%(name)s". If you wish to proceed with the change, please follow
-the link below:
-
- %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
-
-You should then receive another email with the new password.
-'''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
- if not self.standard_message([address], subject, body):
- return
-
- self.ok_message.append('Email sent to %s'%address)
-
- def editItemAction(self):
- ''' Perform an edit of an item in the database.
-
- See parsePropsFromForm and _editnodes for special variables
- '''
- props, links = self.parsePropsFromForm()
-
- # handle the props
- try:
- message = self._editnodes(props, links)
- except (ValueError, KeyError, IndexError), message:
- self.error_message.append(_('Apply Error: ') + str(message))
- return
-
- # commit now that all the tricky stuff is done
- self.db.commit()
-
- # redirect to the item's edit page
- raise Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
- self.classname, self.nodeid, urllib.quote(message),
- urllib.quote(self.template))
-
- newItemAction = editItemAction
-
- def editItemPermission(self, props):
- """Determine whether the user has permission to edit this item.
-
- Base behaviour is to check the user can edit this class. If we're
- editing the"user" class, users are allowed to edit their own details.
- Unless it's the "roles" property, which requires the special Permission
- "Web Roles".
- """
- # if this is a user node and the user is editing their own node, then
- # we're OK
- has = self.db.security.hasPermission
- if self.classname == 'user':
- # reject if someone's trying to edit "roles" and doesn't have the
- # right permission.
- if props.has_key('roles') and not has('Web Roles', self.userid,
- 'user'):
- return 0
- # if the item being edited is the current user, we're ok
- if (self.nodeid == self.userid
- and self.db.user.get(self.nodeid, 'username') != 'anonymous'):
- return 1
- if self.db.security.hasPermission('Edit', self.userid, self.classname):
- return 1
- return 0
-
- def newItemPermission(self, props):
- ''' Determine whether the user has permission to create (edit) this
- item.
-
- Base behaviour is to check the user can edit this class. No
- additional property checks are made. Additionally, new user items
- may be created if the user has the "Web Registration" Permission.
- '''
- has = self.db.security.hasPermission
- if self.classname == 'user' and has('Web Registration', self.userid,
- 'user'):
- return 1
- if has('Edit', self.userid, self.classname):
- return 1
- return 0
-
-
- #
- # Utility methods for editing
- #
- def _editnodes(self, all_props, all_links, newids=None):
- ''' Use the props in all_props to perform edit and creation, then
- use the link specs in all_links to do linking.
- '''
- # figure dependencies and re-work links
- deps = {}
- links = {}
- for cn, nodeid, propname, vlist in all_links:
- if not all_props.has_key((cn, nodeid)):
- # link item to link to doesn't (and won't) exist
- continue
- for value in vlist:
- if not all_props.has_key(value):
- # link item to link to doesn't (and won't) exist
- continue
- deps.setdefault((cn, nodeid), []).append(value)
- links.setdefault(value, []).append((cn, nodeid, propname))
-
- # figure chained dependencies ordering
- order = []
- done = {}
- # loop detection
- change = 0
- while len(all_props) != len(done):
- for needed in all_props.keys():
- if done.has_key(needed):
- continue
- tlist = deps.get(needed, [])
- for target in tlist:
- if not done.has_key(target):
- break
- else:
- done[needed] = 1
- order.append(needed)
- change = 1
- if not change:
- raise ValueError, 'linking must not loop!'
-
- # now, edit / create
- m = []
- for needed in order:
- props = all_props[needed]
- if not props:
- # nothing to do
- continue
- cn, nodeid = needed
-
- if nodeid is not None and int(nodeid) > 0:
- # make changes to the node
- props = self._changenode(cn, nodeid, props)
-
- # and some nice feedback for the user
- if props:
- info = ', '.join(props.keys())
- m.append('%s %s %s edited ok'%(cn, nodeid, info))
- else:
- m.append('%s %s - nothing changed'%(cn, nodeid))
- else:
- assert props
-
- # make a new node
- newid = self._createnode(cn, props)
- if nodeid is None:
- self.nodeid = newid
- nodeid = newid
-
- # and some nice feedback for the user
- m.append('%s %s created'%(cn, newid))
-
- # fill in new ids in links
- if links.has_key(needed):
- for linkcn, linkid, linkprop in links[needed]:
- props = all_props[(linkcn, linkid)]
- cl = self.db.classes[linkcn]
- propdef = cl.getprops()[linkprop]
- if not props.has_key(linkprop):
- if linkid is None or linkid.startswith('-'):
- # linking to a new item
- if isinstance(propdef, hyperdb.Multilink):
- props[linkprop] = [newid]
- else:
- props[linkprop] = newid
- else:
- # linking to an existing item
- if isinstance(propdef, hyperdb.Multilink):
- existing = cl.get(linkid, linkprop)[:]
- existing.append(nodeid)
- props[linkprop] = existing
- else:
- props[linkprop] = newid
-
- return '<br>'.join(m)
-
- def _changenode(self, cn, nodeid, props):
- ''' change the node based on the contents of the form
- '''
- # check for permission
- if not self.editItemPermission(props):
- raise Unauthorised, 'You do not have permission to edit %s'%cn
-
- # make the changes
- cl = self.db.classes[cn]
- return cl.set(nodeid, **props)
-
- def _createnode(self, cn, props):
- ''' create a node based on the contents of the form
- '''
- # check for permission
- if not self.newItemPermission(props):
- raise Unauthorised, 'You do not have permission to create %s'%cn
-
- # create the node and return its id
- cl = self.db.classes[cn]
- return cl.create(**props)
-
- #
- # More actions
- #
- def editCSVAction(self):
- """ Performs an edit of all of a class' items in one go.
-
- The "rows" CGI var defines the CSV-formatted entries for the
- class. New nodes are identified by the ID 'X' (or any other
- non-existent ID) and removed lines are retired.
- """
- # this is per-class only
- if not self.editCSVPermission():
- self.error_message.append(
- _('You do not have permission to edit %s' %self.classname))
- return
-
- # get the CSV module
- if rcsv.error:
- self.error_message.append(_(rcsv.error))
- return
-
- cl = self.db.classes[self.classname]
- idlessprops = cl.getprops(protected=0).keys()
- idlessprops.sort()
- props = ['id'] + idlessprops
-
- # do the edit
- rows = StringIO.StringIO(self.form['rows'].value)
- reader = rcsv.reader(rows, rcsv.comma_separated)
- found = {}
- line = 0
- for values in reader:
- line += 1
- if line == 1: continue
- # skip property names header
- if values == props:
- continue
-
- # extract the nodeid
- nodeid, values = values[0], values[1:]
- found[nodeid] = 1
-
- # see if the node exists
- if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
- exists = 0
- else:
- exists = 1
-
- # confirm correct weight
- if len(idlessprops) != len(values):
- self.error_message.append(
- _('Not enough values on line %(line)s')%{'line':line})
- return
-
- # extract the new values
- d = {}
- for name, value in zip(idlessprops, values):
- prop = cl.properties[name]
- value = value.strip()
- # only add the property if it has a value
- if value:
- # if it's a multilink, split it
- if isinstance(prop, hyperdb.Multilink):
- value = value.split(':')
- elif isinstance(prop, hyperdb.Password):
- value = password.Password(value)
- elif isinstance(prop, hyperdb.Interval):
- value = date.Interval(value)
- elif isinstance(prop, hyperdb.Date):
- value = date.Date(value)
- elif isinstance(prop, hyperdb.Boolean):
- value = value.lower() in ('yes', 'true', 'on', '1')
- elif isinstance(prop, hyperdb.Number):
- value = float(value)
- d[name] = value
- elif exists:
- # nuke the existing value
- if isinstance(prop, hyperdb.Multilink):
- d[name] = []
- else:
- d[name] = None
-
- # perform the edit
- if exists:
- # 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)
-
- # all OK
- self.db.commit()
-
- self.ok_message.append(_('Items edited OK'))
-
- def editCSVPermission(self):
- ''' Determine whether the user has permission to edit this class.
-
- Base behaviour is to check the user can edit this class.
- '''
- if not self.db.security.hasPermission('Edit', self.userid,
- self.classname):
- return 0
- return 1
-
- def searchAction(self, wcre=re.compile(r'[\s,]+')):
- ''' Mangle some of the form variables.
-
- Set the form ":filter" variable based on the values of the
- filter variables - if they're set to anything other than
- "dontcare" then add them to :filter.
-
- Handle the ":queryname" variable and save off the query to
- the user's query list.
-
- Split any String query values on whitespace and comma.
- '''
- # generic edit is per-class only
- if not self.searchPermission():
- self.error_message.append(
- _('You do not have permission to search %s' %self.classname))
- return
-
- # add a faked :filter form variable for each filtering prop
- props = self.db.classes[self.classname].getprops()
- queryname = ''
- for key in self.form.keys():
- # special vars
- if self.FV_QUERYNAME.match(key):
- queryname = self.form[key].value.strip()
- continue
-
- if not props.has_key(key):
- continue
- if isinstance(self.form[key], type([])):
- # search for at least one entry which is not empty
- for minifield in self.form[key]:
- if minifield.value:
- break
- else:
- continue
- else:
- if not self.form[key].value:
- continue
- if isinstance(props[key], hyperdb.String):
- v = self.form[key].value
- l = token.token_split(v)
- if len(l) > 1 or l[0] != v:
- self.form.value.remove(self.form[key])
- # replace the single value with the split list
- for v in l:
- self.form.value.append(cgi.MiniFieldStorage(key, v))
-
- self.form.value.append(cgi.MiniFieldStorage('@filter', key))
-
- # handle saving the query params
- if queryname:
- # parse the environment and figure what the query _is_
- req = templating.HTMLRequest(self)
-
- # The [1:] strips off the '?' character, it isn't part of the
- # query string.
- url = req.indexargs_href('', {})[1:]
-
- # handle editing an existing query
- try:
- qid = self.db.query.lookup(queryname)
- self.db.query.set(qid, klass=self.classname, url=url)
- except KeyError:
- # create a query
- qid = self.db.query.create(name=queryname,
- klass=self.classname, url=url)
-
- # and add it to the user's query multilink
- queries = self.db.user.get(self.userid, 'queries')
- queries.append(qid)
- self.db.user.set(self.userid, queries=queries)
-
- # commit the query change to the database
- self.db.commit()
-
- def searchPermission(self):
- ''' Determine whether the user has permission to search this class.
-
- Base behaviour is to check the user can view this class.
- '''
- if not self.db.security.hasPermission('View', self.userid,
- self.classname):
- return 0
- return 1
-
-
- def retireAction(self):
- ''' Retire the context item.
- '''
- # if we want to view the index template now, then unset the nodeid
- # context info (a special-case for retire actions on the index page)
- nodeid = self.nodeid
- if self.template == 'index':
- self.nodeid = None
-
- # generic edit is per-class only
- if not self.retirePermission():
- self.error_message.append(
- _('You do not have permission to retire %s' %self.classname))
- return
-
- # make sure we don't try to retire admin or anonymous
- if self.classname == 'user' and \
- self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
- self.error_message.append(
- _('You may not retire the admin or anonymous user'))
- return
-
- # do the retire
- self.db.getclass(self.classname).retire(nodeid)
- self.db.commit()
-
- self.ok_message.append(
- _('%(classname)s %(itemid)s has been retired')%{
- 'classname': self.classname.capitalize(), 'itemid': nodeid})
-
- def retirePermission(self):
- ''' Determine whether the user has permission to retire this class.
-
- Base behaviour is to check the user can edit this class.
- '''
- if not self.db.security.hasPermission('Edit', self.userid,
- self.classname):
- return 0
- return 1
-
-
- def showAction(self, typere=re.compile('[@:]type'),
- numre=re.compile('[@:]number')):
- ''' Show a node of a particular class/id
- '''
- t = n = ''
- for key in self.form.keys():
- if typere.match(key):
- t = self.form[key].value.strip()
- elif numre.match(key):
- n = self.form[key].value.strip()
- if not t:
- raise ValueError, 'Invalid %s number'%t
- url = '%s%s%s'%(self.db.config.TRACKER_WEB, t, n)
- raise Redirect, url
-
- def parsePropsFromForm(self, num_re=re.compile('^\d+$')):
- """ Item properties and their values are edited with html FORM
- variables and their values. You can:
-
- - Change the value of some property of the current item.
- - Create a new item of any class, and edit the new item's
- properties,
- - Attach newly created items to a multilink property of the
- current item.
- - Remove items from a multilink property of the current item.
- - Specify that some properties are required for the edit
- operation to be successful.
-
- In the following, <bracketed> values are variable, "@" may be
- either ":" or "@", and other text "required" is fixed.
-
- Most properties are specified as form variables:
-
- <propname>
- - property on the current context item
-
- <designator>"@"<propname>
- - property on the indicated item (for editing related
- information)
-
- Designators name a specific item of a class.
-
- <classname><N>
-
- Name an existing item of class <classname>.
-
- <classname>"-"<N>
-
- Name the <N>th new item of class <classname>. If the form
- submission is successful, a new item of <classname> is
- created. Within the submitted form, a particular
- designator of this form always refers to the same new
- item.
-
- Once we have determined the "propname", we look at it to see
- if it's special:
-
- @required
- The associated form value is a comma-separated list of
- property names that must be specified when the form is
- submitted for the edit operation to succeed.
-
- When the <designator> is missing, the properties are
- for the current context item. When <designator> is
- present, they are for the item specified by
- <designator>.
-
- The "@required" specifier must come before any of the
- properties it refers to are assigned in the form.
-
- @remove@<propname>=id(s) or @add@<propname>=id(s)
- The "@add@" and "@remove@" edit actions apply only to
- Multilink properties. The form value must be a
- comma-separate list of keys for the class specified by
- the simple form variable. The listed items are added
- to (respectively, removed from) the specified
- property.
-
- @link@<propname>=<designator>
- If the edit action is "@link@", the simple form
- variable must specify a Link or Multilink property.
- The form value is a comma-separated list of
- designators. The item corresponding to each
- designator is linked to the property given by simple
- form variable. These are collected up and returned in
- all_links.
-
- None of the above (ie. just a simple form value)
- The value of the form variable is converted
- appropriately, depending on the type of the property.
-
- For a Link('klass') property, the form value is a
- single key for 'klass', where the key field is
- specified in dbinit.py.
-
- For a Multilink('klass') property, the form value is a
- comma-separated list of keys for 'klass', where the
- key field is specified in dbinit.py.
-
- Note that for simple-form-variables specifiying Link
- and Multilink properties, the linked-to class must
- have a key field.
-
- For a String() property specifying a filename, the
- file named by the form value is uploaded. This means we
- try to set additional properties "filename" and "type" (if
- they are valid for the class). Otherwise, the property
- is set to the form value.
-
- For Date(), Interval(), Boolean(), and Number()
- properties, the form value is converted to the
- appropriate
-
- Any of the form variables may be prefixed with a classname or
- designator.
-
- Two special form values are supported for backwards
- compatibility:
-
- @note
- This is equivalent to::
-
- @link@messages=msg-1
- msg-1@content=value
-
- except that in addition, the "author" and "date"
- properties of "msg-1" are set to the userid of the
- submitter, and the current time, respectively.
-
- @file
- This is equivalent to::
-
- @link@files=file-1
- file-1@content=value
-
- The String content value is handled as described above for
- file uploads.
-
- If both the "@note" and "@file" form variables are
- specified, the action::
-
- @link@msg-1@files=file-1
-
- is also performed.
-
- We also check that FileClass items have a "content" property with
- actual content, otherwise we remove them from all_props before
- returning.
-
- The return from this method is a dict of
- (classname, id): properties
- ... this dict _always_ has an entry for the current context,
- even if it's empty (ie. a submission for an existing issue that
- doesn't result in any changes would return {('issue','123'): {}})
- The id may be None, which indicates that an item should be
- created.
- """
- # some very useful variables
- db = self.db
- form = self.form
-
- if not hasattr(self, 'FV_SPECIAL'):
- # generate the regexp for handling special form values
- classes = '|'.join(db.classes.keys())
- # specials for parsePropsFromForm
- # handle the various forms (see unit tests)
- self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
- self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
-
- # these indicate the default class / item
- default_cn = self.classname
- default_cl = self.db.classes[default_cn]
- default_nodeid = self.nodeid
-
- # we'll store info about the individual class/item edit in these
- all_required = {} # required props per class/item
- all_props = {} # props to set per class/item
- got_props = {} # props received per class/item
- all_propdef = {} # note - only one entry per class
- all_links = [] # as many as are required
-
- # we should always return something, even empty, for the context
- all_props[(default_cn, default_nodeid)] = {}
-
- keys = form.keys()
- timezone = db.getUserTimezone()
-
- # sentinels for the :note and :file props
- have_note = have_file = 0
-
- # extract the usable form labels from the form
- matches = []
- for key in keys:
- m = self.FV_SPECIAL.match(key)
- if m:
- matches.append((key, m.groupdict()))
-
- # now handle the matches
- for key, d in matches:
- if d['classname']:
- # we got a designator
- cn = d['classname']
- cl = self.db.classes[cn]
- nodeid = d['id']
- propname = d['propname']
- elif d['note']:
- # the special note field
- cn = 'msg'
- cl = self.db.classes[cn]
- nodeid = '-1'
- propname = 'content'
- all_links.append((default_cn, default_nodeid, 'messages',
- [('msg', '-1')]))
- have_note = 1
- elif d['file']:
- # the special file field
- cn = 'file'
- cl = self.db.classes[cn]
- nodeid = '-1'
- propname = 'content'
- all_links.append((default_cn, default_nodeid, 'files',
- [('file', '-1')]))
- have_file = 1
- else:
- # default
- cn = default_cn
- cl = default_cl
- nodeid = default_nodeid
- propname = d['propname']
-
- # the thing this value relates to is...
- this = (cn, nodeid)
-
- # get more info about the class, and the current set of
- # form props for it
- if not all_propdef.has_key(cn):
- all_propdef[cn] = cl.getprops()
- propdef = all_propdef[cn]
- if not all_props.has_key(this):
- all_props[this] = {}
- props = all_props[this]
- if not got_props.has_key(this):
- got_props[this] = {}
-
- # is this a link command?
- if d['link']:
- value = []
- for entry in extractFormList(form[key]):
- m = self.FV_DESIGNATOR.match(entry)
- if not m:
- raise FormError, \
- 'link "%s" value "%s" not a designator'%(key, entry)
- value.append((m.group(1), m.group(2)))
-
- # make sure the link property is valid
- if (not isinstance(propdef[propname], hyperdb.Multilink) and
- not isinstance(propdef[propname], hyperdb.Link)):
- raise FormError, '%s %s is not a link or '\
- 'multilink property'%(cn, propname)
-
- all_links.append((cn, nodeid, propname, value))
- continue
-
- # detect the special ":required" variable
- if d['required']:
- all_required[this] = extractFormList(form[key])
- continue
-
- # see if we're performing a special multilink action
- mlaction = 'set'
- if d['remove']:
- mlaction = 'remove'
- elif d['add']:
- mlaction = 'add'
-
- # does the property exist?
- if not propdef.has_key(propname):
- if mlaction != 'set':
- raise FormError, 'You have submitted a %s action for'\
- ' the property "%s" which doesn\'t exist'%(mlaction,
- propname)
- # the form element is probably just something we don't care
- # about - ignore it
- continue
- proptype = propdef[propname]
-
- # Get the form value. This value may be a MiniFieldStorage or a list
- # of MiniFieldStorages.
- value = form[key]
-
- # handle unpacking of the MiniFieldStorage / list form value
- if isinstance(proptype, hyperdb.Multilink):
- value = extractFormList(value)
- else:
- # multiple values are not OK
- if isinstance(value, type([])):
- raise FormError, 'You have submitted more than one value'\
- ' for the %s property'%propname
- # value might be a file upload...
- if not hasattr(value, 'filename') or value.filename is None:
- # nope, pull out the value and strip it
- value = value.value.strip()
-
- # now that we have the props field, we need a teensy little
- # extra bit of help for the old :note field...
- if d['note'] and value:
- props['author'] = self.db.getuid()
- props['date'] = date.Date()
-
- # handle by type now
- if isinstance(proptype, hyperdb.Password):
- if not value:
- # ignore empty password values
- continue
- for key, d in matches:
- if d['confirm'] and d['propname'] == propname:
- confirm = form[key]
- break
- else:
- raise FormError, 'Password and confirmation text do '\
- 'not match'
- if isinstance(confirm, type([])):
- raise FormError, 'You have submitted more than one value'\
- ' for the %s property'%propname
- if value != confirm.value:
- raise FormError, 'Password and confirmation text do '\
- 'not match'
- try:
- value = password.Password(value)
- except hyperdb.HyperdbValueError, msg:
- raise FormError, msg
-
- elif isinstance(proptype, hyperdb.Multilink):
- # convert input to list of ids
- try:
- l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
- propname, value)
- except hyperdb.HyperdbValueError, msg:
- raise FormError, msg
-
- # now use that list of ids to modify the multilink
- if mlaction == 'set':
- value = l
- else:
- # we're modifying the list - get the current list of ids
- if props.has_key(propname):
- existing = props[propname]
- elif nodeid and not nodeid.startswith('-'):
- existing = cl.get(nodeid, propname, [])
- else:
- existing = []
-
- # now either remove or add
- if mlaction == 'remove':
- # remove - handle situation where the id isn't in
- # the list
- for entry in l:
- try:
- existing.remove(entry)
- except ValueError:
- raise FormError, _('property "%(propname)s": '
- '"%(value)s" not currently in list')%{
- 'propname': propname, 'value': entry}
- else:
- # add - easy, just don't dupe
- for entry in l:
- if entry not in existing:
- existing.append(entry)
- value = existing
- value.sort()
-
- elif value == '':
- # other types should be None'd if there's no value
- value = None
- else:
- # handle all other types
- try:
- if isinstance(proptype, hyperdb.String):
- if (hasattr(value, 'filename') and
- value.filename is not None):
- # skip if the upload is empty
- if not value.filename:
- continue
- # this String is actually a _file_
- # try to determine the file content-type
- fn = value.filename.split('\\')[-1]
- if propdef.has_key('name'):
- props['name'] = fn
- # use this info as the type/filename properties
- if propdef.has_key('type'):
- if hasattr(value, 'type') and value.type:
- props['type'] = value.type
- elif mimetypes.guess_type(fn)[0]:
- props['type'] = mimetypes.guess_type(fn)[0]
- else:
- props['type'] = "application/octet-stream"
- # finally, read the content RAW
- value = value.value
- else:
- value = hyperdb.rawToHyperdb(self.db, cl,
- nodeid, propname, value)
-
- else:
- value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
- propname, value)
- except hyperdb.HyperdbValueError, msg:
- raise FormError, msg
-
- # register that we got this property
- if value:
- got_props[this][propname] = 1
-
- # get the old value
- if nodeid and not nodeid.startswith('-'):
- try:
- existing = cl.get(nodeid, propname)
- except KeyError:
- # this might be a new property for which there is
- # no existing value
- if not propdef.has_key(propname):
- raise
- except IndexError, message:
- raise FormError(str(message))
-
- # make sure the existing multilink is sorted
- if isinstance(proptype, hyperdb.Multilink):
- existing.sort()
-
- # "missing" existing values may not be None
- if not existing:
- if isinstance(proptype, hyperdb.String) and not existing:
- # some backends store "missing" Strings as empty strings
- existing = None
- elif isinstance(proptype, hyperdb.Number) and not existing:
- # some backends store "missing" Numbers as 0 :(
- existing = 0
- elif isinstance(proptype, hyperdb.Boolean) and not existing:
- # likewise Booleans
- existing = 0
-
- # if changed, set it
- if value != existing:
- props[propname] = value
- else:
- # don't bother setting empty/unset values
- if value is None:
- continue
- elif isinstance(proptype, hyperdb.Multilink) and value == []:
- continue
- elif isinstance(proptype, hyperdb.String) and value == '':
- continue
-
- props[propname] = value
-
- # check to see if we need to specially link a file to the note
- if have_note and have_file:
- all_links.append(('msg', '-1', 'files', [('file', '-1')]))
-
- # see if all the required properties have been supplied
- s = []
- for thing, required in all_required.items():
- # register the values we got
- got = got_props.get(thing, {})
- for entry in required[:]:
- if got.has_key(entry):
- required.remove(entry)
-
- # any required values not present?
- if not required:
- continue
-
- # tell the user to entry the values required
- if len(required) > 1:
- p = 'properties'
- else:
- p = 'property'
- s.append('Required %s %s %s not supplied'%(thing[0], p,
- ', '.join(required)))
- if s:
- raise FormError, '\n'.join(s)
-
- # When creating a FileClass node, it should have a non-empty content
- # property to be created. When editing a FileClass node, it should
- # either have a non-empty content property or no property at all. In
- # the latter case, nothing will change.
- for (cn, id), props in all_props.items():
- if isinstance(self.db.classes[cn], hyperdb.FileClass):
- if id == '-1':
- if not props.get('content', ''):
- del all_props[(cn, id)]
- elif props.has_key('content') and not props['content']:
- raise FormError, _('File is empty')
- return all_props, all_links
-
-def extractFormList(value):
- ''' Extract a list of values from the form value.
-
- It may be one of:
- [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
- MiniFieldStorage('value,value,...')
- MiniFieldStorage('value')
- '''
- # multiple values are OK
- if isinstance(value, type([])):
- # it's a list of MiniFieldStorages - join then into
- values = ','.join([i.value.strip() for i in value])
- else:
- # it's a MiniFieldStorage, but may be a comma-separated list
- # of values
- values = value.value
-
- value = [i.strip() for i in values.split(',')]
-
- # filter out the empty bits
- return filter(None, value)
+ def parsePropsFromForm(self):
+ return FormParser(self).parse()
diff --git a/roundup/cgi/exceptions.py b/roundup/cgi/exceptions.py
--- /dev/null
@@ -0,0 +1,32 @@
+class HTTPException(Exception):
+ pass
+
+class Unauthorised(HTTPException):
+ pass
+
+class Redirect(HTTPException):
+ pass
+
+class NotFound(HTTPException):
+ pass
+
+class NotModified(HTTPException):
+ pass
+
+class FormError(ValueError):
+ """An 'expected' exception occurred during form parsing.
+
+ That is, something we know can go wrong, and don't want to alarm the user
+ with.
+
+ We trap this at the user interface level and feed back a nice error to the
+ user.
+
+ """
+ pass
+
+class SendFile(Exception):
+ """Send a file from the database."""
+
+class SendStaticFile(Exception):
+ """Send a static file from the instance html directory."""
diff --git a/roundup/cgi/form_parser.py b/roundup/cgi/form_parser.py
--- /dev/null
@@ -0,0 +1,536 @@
+import re, mimetypes
+
+from roundup import hyperdb, date, password
+from roundup.cgi.exceptions import FormError
+from roundup.i18n import _
+
+class FormParser:
+ # edit form variable handling (see unit tests)
+ FV_LABELS = r'''
+ ^(
+ (?P<note>[@:]note)|
+ (?P<file>[@:]file)|
+ (
+ ((?P<classname>%s)(?P<id>[-\d]+))? # optional leading designator
+ ((?P<required>[@:]required$)| # :required
+ (
+ (
+ (?P<add>[@:]add[@:])| # :add:<prop>
+ (?P<remove>[@:]remove[@:])| # :remove:<prop>
+ (?P<confirm>[@:]confirm[@:])| # :confirm:<prop>
+ (?P<link>[@:]link[@:])| # :link:<prop>
+ ([@:]) # just a separator
+ )?
+ (?P<propname>[^@:]+) # <prop>
+ )
+ )
+ )
+ )$'''
+
+ def __init__(self, client):
+ self.client = client
+ self.db = client.db
+ self.form = client.form
+ self.classname = client.classname
+ self.nodeid = client.nodeid
+
+ def parse(self, num_re=re.compile('^\d+$')):
+ """ Item properties and their values are edited with html FORM
+ variables and their values. You can:
+
+ - Change the value of some property of the current item.
+ - Create a new item of any class, and edit the new item's
+ properties,
+ - Attach newly created items to a multilink property of the
+ current item.
+ - Remove items from a multilink property of the current item.
+ - Specify that some properties are required for the edit
+ operation to be successful.
+
+ In the following, <bracketed> values are variable, "@" may be
+ either ":" or "@", and other text "required" is fixed.
+
+ Most properties are specified as form variables:
+
+ <propname>
+ - property on the current context item
+
+ <designator>"@"<propname>
+ - property on the indicated item (for editing related
+ information)
+
+ Designators name a specific item of a class.
+
+ <classname><N>
+
+ Name an existing item of class <classname>.
+
+ <classname>"-"<N>
+
+ Name the <N>th new item of class <classname>. If the form
+ submission is successful, a new item of <classname> is
+ created. Within the submitted form, a particular
+ designator of this form always refers to the same new
+ item.
+
+ Once we have determined the "propname", we look at it to see
+ if it's special:
+
+ @required
+ The associated form value is a comma-separated list of
+ property names that must be specified when the form is
+ submitted for the edit operation to succeed.
+
+ When the <designator> is missing, the properties are
+ for the current context item. When <designator> is
+ present, they are for the item specified by
+ <designator>.
+
+ The "@required" specifier must come before any of the
+ properties it refers to are assigned in the form.
+
+ @remove@<propname>=id(s) or @add@<propname>=id(s)
+ The "@add@" and "@remove@" edit actions apply only to
+ Multilink properties. The form value must be a
+ comma-separate list of keys for the class specified by
+ the simple form variable. The listed items are added
+ to (respectively, removed from) the specified
+ property.
+
+ @link@<propname>=<designator>
+ If the edit action is "@link@", the simple form
+ variable must specify a Link or Multilink property.
+ The form value is a comma-separated list of
+ designators. The item corresponding to each
+ designator is linked to the property given by simple
+ form variable. These are collected up and returned in
+ all_links.
+
+ None of the above (ie. just a simple form value)
+ The value of the form variable is converted
+ appropriately, depending on the type of the property.
+
+ For a Link('klass') property, the form value is a
+ single key for 'klass', where the key field is
+ specified in dbinit.py.
+
+ For a Multilink('klass') property, the form value is a
+ comma-separated list of keys for 'klass', where the
+ key field is specified in dbinit.py.
+
+ Note that for simple-form-variables specifiying Link
+ and Multilink properties, the linked-to class must
+ have a key field.
+
+ For a String() property specifying a filename, the
+ file named by the form value is uploaded. This means we
+ try to set additional properties "filename" and "type" (if
+ they are valid for the class). Otherwise, the property
+ is set to the form value.
+
+ For Date(), Interval(), Boolean(), and Number()
+ properties, the form value is converted to the
+ appropriate
+
+ Any of the form variables may be prefixed with a classname or
+ designator.
+
+ Two special form values are supported for backwards
+ compatibility:
+
+ @note
+ This is equivalent to::
+
+ @link@messages=msg-1
+ msg-1@content=value
+
+ except that in addition, the "author" and "date"
+ properties of "msg-1" are set to the userid of the
+ submitter, and the current time, respectively.
+
+ @file
+ This is equivalent to::
+
+ @link@files=file-1
+ file-1@content=value
+
+ The String content value is handled as described above for
+ file uploads.
+
+ If both the "@note" and "@file" form variables are
+ specified, the action::
+
+ @link@msg-1@files=file-1
+
+ is also performed.
+
+ We also check that FileClass items have a "content" property with
+ actual content, otherwise we remove them from all_props before
+ returning.
+
+ The return from this method is a dict of
+ (classname, id): properties
+ ... this dict _always_ has an entry for the current context,
+ even if it's empty (ie. a submission for an existing issue that
+ doesn't result in any changes would return {('issue','123'): {}})
+ The id may be None, which indicates that an item should be
+ created.
+ """
+ # some very useful variables
+ db = self.db
+ form = self.form
+
+ if not hasattr(self, 'FV_SPECIAL'):
+ # generate the regexp for handling special form values
+ classes = '|'.join(db.classes.keys())
+ # specials for parsePropsFromForm
+ # handle the various forms (see unit tests)
+ self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
+ self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
+
+ # these indicate the default class / item
+ default_cn = self.classname
+ default_cl = self.db.classes[default_cn]
+ default_nodeid = self.nodeid
+
+ # we'll store info about the individual class/item edit in these
+ all_required = {} # required props per class/item
+ all_props = {} # props to set per class/item
+ got_props = {} # props received per class/item
+ all_propdef = {} # note - only one entry per class
+ all_links = [] # as many as are required
+
+ # we should always return something, even empty, for the context
+ all_props[(default_cn, default_nodeid)] = {}
+
+ keys = form.keys()
+ timezone = db.getUserTimezone()
+
+ # sentinels for the :note and :file props
+ have_note = have_file = 0
+
+ # extract the usable form labels from the form
+ matches = []
+ for key in keys:
+ m = self.FV_SPECIAL.match(key)
+ if m:
+ matches.append((key, m.groupdict()))
+
+ # now handle the matches
+ for key, d in matches:
+ if d['classname']:
+ # we got a designator
+ cn = d['classname']
+ cl = self.db.classes[cn]
+ nodeid = d['id']
+ propname = d['propname']
+ elif d['note']:
+ # the special note field
+ cn = 'msg'
+ cl = self.db.classes[cn]
+ nodeid = '-1'
+ propname = 'content'
+ all_links.append((default_cn, default_nodeid, 'messages',
+ [('msg', '-1')]))
+ have_note = 1
+ elif d['file']:
+ # the special file field
+ cn = 'file'
+ cl = self.db.classes[cn]
+ nodeid = '-1'
+ propname = 'content'
+ all_links.append((default_cn, default_nodeid, 'files',
+ [('file', '-1')]))
+ have_file = 1
+ else:
+ # default
+ cn = default_cn
+ cl = default_cl
+ nodeid = default_nodeid
+ propname = d['propname']
+
+ # the thing this value relates to is...
+ this = (cn, nodeid)
+
+ # get more info about the class, and the current set of
+ # form props for it
+ if not all_propdef.has_key(cn):
+ all_propdef[cn] = cl.getprops()
+ propdef = all_propdef[cn]
+ if not all_props.has_key(this):
+ all_props[this] = {}
+ props = all_props[this]
+ if not got_props.has_key(this):
+ got_props[this] = {}
+
+ # is this a link command?
+ if d['link']:
+ value = []
+ for entry in self.extractFormList(form[key]):
+ m = self.FV_DESIGNATOR.match(entry)
+ if not m:
+ raise FormError, \
+ 'link "%s" value "%s" not a designator'%(key, entry)
+ value.append((m.group(1), m.group(2)))
+
+ # make sure the link property is valid
+ if (not isinstance(propdef[propname], hyperdb.Multilink) and
+ not isinstance(propdef[propname], hyperdb.Link)):
+ raise FormError, '%s %s is not a link or '\
+ 'multilink property'%(cn, propname)
+
+ all_links.append((cn, nodeid, propname, value))
+ continue
+
+ # detect the special ":required" variable
+ if d['required']:
+ all_required[this] = self.extractFormList(form[key])
+ continue
+
+ # see if we're performing a special multilink action
+ mlaction = 'set'
+ if d['remove']:
+ mlaction = 'remove'
+ elif d['add']:
+ mlaction = 'add'
+
+ # does the property exist?
+ if not propdef.has_key(propname):
+ if mlaction != 'set':
+ raise FormError, 'You have submitted a %s action for'\
+ ' the property "%s" which doesn\'t exist'%(mlaction,
+ propname)
+ # the form element is probably just something we don't care
+ # about - ignore it
+ continue
+ proptype = propdef[propname]
+
+ # Get the form value. This value may be a MiniFieldStorage or a list
+ # of MiniFieldStorages.
+ value = form[key]
+
+ # handle unpacking of the MiniFieldStorage / list form value
+ if isinstance(proptype, hyperdb.Multilink):
+ value = self.extractFormList(value)
+ else:
+ # multiple values are not OK
+ if isinstance(value, type([])):
+ raise FormError, 'You have submitted more than one value'\
+ ' for the %s property'%propname
+ # value might be a file upload...
+ if not hasattr(value, 'filename') or value.filename is None:
+ # nope, pull out the value and strip it
+ value = value.value.strip()
+
+ # now that we have the props field, we need a teensy little
+ # extra bit of help for the old :note field...
+ if d['note'] and value:
+ props['author'] = self.db.getuid()
+ props['date'] = date.Date()
+
+ # handle by type now
+ if isinstance(proptype, hyperdb.Password):
+ if not value:
+ # ignore empty password values
+ continue
+ for key, d in matches:
+ if d['confirm'] and d['propname'] == propname:
+ confirm = form[key]
+ break
+ else:
+ raise FormError, 'Password and confirmation text do '\
+ 'not match'
+ if isinstance(confirm, type([])):
+ raise FormError, 'You have submitted more than one value'\
+ ' for the %s property'%propname
+ if value != confirm.value:
+ raise FormError, 'Password and confirmation text do '\
+ 'not match'
+ try:
+ value = password.Password(value)
+ except hyperdb.HyperdbValueError, msg:
+ raise FormError, msg
+
+ elif isinstance(proptype, hyperdb.Multilink):
+ # convert input to list of ids
+ try:
+ l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+ propname, value)
+ except hyperdb.HyperdbValueError, msg:
+ raise FormError, msg
+
+ # now use that list of ids to modify the multilink
+ if mlaction == 'set':
+ value = l
+ else:
+ # we're modifying the list - get the current list of ids
+ if props.has_key(propname):
+ existing = props[propname]
+ elif nodeid and not nodeid.startswith('-'):
+ existing = cl.get(nodeid, propname, [])
+ else:
+ existing = []
+
+ # now either remove or add
+ if mlaction == 'remove':
+ # remove - handle situation where the id isn't in
+ # the list
+ for entry in l:
+ try:
+ existing.remove(entry)
+ except ValueError:
+ raise FormError, _('property "%(propname)s": '
+ '"%(value)s" not currently in list')%{
+ 'propname': propname, 'value': entry}
+ else:
+ # add - easy, just don't dupe
+ for entry in l:
+ if entry not in existing:
+ existing.append(entry)
+ value = existing
+ value.sort()
+
+ elif value == '':
+ # other types should be None'd if there's no value
+ value = None
+ else:
+ # handle all other types
+ try:
+ if isinstance(proptype, hyperdb.String):
+ if (hasattr(value, 'filename') and
+ value.filename is not None):
+ # skip if the upload is empty
+ if not value.filename:
+ continue
+ # this String is actually a _file_
+ # try to determine the file content-type
+ fn = value.filename.split('\\')[-1]
+ if propdef.has_key('name'):
+ props['name'] = fn
+ # use this info as the type/filename properties
+ if propdef.has_key('type'):
+ if hasattr(value, 'type') and value.type:
+ props['type'] = value.type
+ elif mimetypes.guess_type(fn)[0]:
+ props['type'] = mimetypes.guess_type(fn)[0]
+ else:
+ props['type'] = "application/octet-stream"
+ # finally, read the content RAW
+ value = value.value
+ else:
+ value = hyperdb.rawToHyperdb(self.db, cl,
+ nodeid, propname, value)
+
+ else:
+ value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+ propname, value)
+ except hyperdb.HyperdbValueError, msg:
+ raise FormError, msg
+
+ # register that we got this property
+ if value:
+ got_props[this][propname] = 1
+
+ # get the old value
+ if nodeid and not nodeid.startswith('-'):
+ try:
+ existing = cl.get(nodeid, propname)
+ except KeyError:
+ # this might be a new property for which there is
+ # no existing value
+ if not propdef.has_key(propname):
+ raise
+ except IndexError, message:
+ raise FormError(str(message))
+
+ # make sure the existing multilink is sorted
+ if isinstance(proptype, hyperdb.Multilink):
+ existing.sort()
+
+ # "missing" existing values may not be None
+ if not existing:
+ if isinstance(proptype, hyperdb.String) and not existing:
+ # some backends store "missing" Strings as empty strings
+ existing = None
+ elif isinstance(proptype, hyperdb.Number) and not existing:
+ # some backends store "missing" Numbers as 0 :(
+ existing = 0
+ elif isinstance(proptype, hyperdb.Boolean) and not existing:
+ # likewise Booleans
+ existing = 0
+
+ # if changed, set it
+ if value != existing:
+ props[propname] = value
+ else:
+ # don't bother setting empty/unset values
+ if value is None:
+ continue
+ elif isinstance(proptype, hyperdb.Multilink) and value == []:
+ continue
+ elif isinstance(proptype, hyperdb.String) and value == '':
+ continue
+
+ props[propname] = value
+
+ # check to see if we need to specially link a file to the note
+ if have_note and have_file:
+ all_links.append(('msg', '-1', 'files', [('file', '-1')]))
+
+ # see if all the required properties have been supplied
+ s = []
+ for thing, required in all_required.items():
+ # register the values we got
+ got = got_props.get(thing, {})
+ for entry in required[:]:
+ if got.has_key(entry):
+ required.remove(entry)
+
+ # any required values not present?
+ if not required:
+ continue
+
+ # tell the user to entry the values required
+ if len(required) > 1:
+ p = 'properties'
+ else:
+ p = 'property'
+ s.append('Required %s %s %s not supplied'%(thing[0], p,
+ ', '.join(required)))
+ if s:
+ raise FormError, '\n'.join(s)
+
+ # When creating a FileClass node, it should have a non-empty content
+ # property to be created. When editing a FileClass node, it should
+ # either have a non-empty content property or no property at all. In
+ # the latter case, nothing will change.
+ for (cn, id), props in all_props.items():
+ if isinstance(self.db.classes[cn], hyperdb.FileClass):
+ if id == '-1':
+ if not props.get('content', ''):
+ del all_props[(cn, id)]
+ elif props.has_key('content') and not props['content']:
+ raise FormError, _('File is empty')
+ return all_props, all_links
+
+ def extractFormList(self, value):
+ ''' Extract a list of values from the form value.
+
+ It may be one of:
+ [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
+ MiniFieldStorage('value,value,...')
+ MiniFieldStorage('value')
+ '''
+ # multiple values are OK
+ if isinstance(value, type([])):
+ # it's a list of MiniFieldStorages - join then into
+ values = ','.join([i.value.strip() for i in value])
+ else:
+ # it's a MiniFieldStorage, but may be a comma-separated list
+ # of values
+ values = value.value
+
+ value = [i.strip() for i in values.split(',')]
+
+ # filter out the empty bits
+ return filter(None, value)
diff --git a/test/test_actions.py b/test/test_actions.py
--- /dev/null
+++ b/test/test_actions.py
@@ -0,0 +1,144 @@
+import unittest\r
+from cgi import FieldStorage, MiniFieldStorage\r
+\r
+from roundup import hyperdb\r
+from roundup.cgi.actions import *\r
+from roundup.cgi.exceptions import Redirect, Unauthorised\r
+\r
+class MockNull:\r
+ def __init__(self, **kwargs):\r
+ for key, value in kwargs.items():\r
+ setattr(self, key, value)\r
+\r
+ def __call__(self, *args, **kwargs): return MockNull()\r
+ def __getattr__(self, name):\r
+ # This allows assignments which assume all intermediate steps are Null\r
+ # objects if they don't exist yet.\r
+ #\r
+ # For example (with just 'client' defined):\r
+ #\r
+ # client.db.config.TRACKER_WEB = 'BASE/' \r
+ setattr(self, name, MockNull())\r
+ return getattr(self, name)\r
+\r
+ def __getitem__(self, key): return self \r
+ def __nonzero__(self): return 0\r
+ def __str__(self): return ''\r
+\r
+def true(*args, **kwargs):\r
+ return 1\r
+\r
+class ActionTestCase(unittest.TestCase):\r
+ def setUp(self):\r
+ self.form = FieldStorage()\r
+ self.client = MockNull()\r
+ self.client.form = self.form\r
+ \r
+class ShowActionTestCase(ActionTestCase):\r
+ def assertRaisesMessage(self, exception, callable, message, *args, **kwargs):\r
+ try:\r
+ callable(*args, **kwargs)\r
+ except exception, msg:\r
+ self.assertEqual(str(msg), message)\r
+ else:\r
+ if hasattr(excClass,'__name__'): excName = excClass.__name__\r
+ else: excName = str(excClass)\r
+ raise self.failureException, excName\r
+ \r
+ def testShowAction(self):\r
+ self.client.db.config.TRACKER_WEB = 'BASE/'\r
+\r
+ action = ShowAction(self.client)\r
+ self.assertRaises(ValueError, action.handle)\r
+\r
+ self.form.value.append(MiniFieldStorage('@type', 'issue'))\r
+ self.assertRaisesMessage(Redirect, action.handle, 'BASE/issue')\r
+ \r
+ self.form.value.append(MiniFieldStorage('@number', '1'))\r
+ self.assertRaisesMessage(Redirect, action.handle, 'BASE/issue1')\r
+\r
+class RetireActionTestCase(ActionTestCase):\r
+ def testRetireAction(self):\r
+ self.client.db.security.hasPermission = true\r
+ self.client.ok_message = []\r
+ RetireAction(self.client).handle()\r
+ self.assert_(len(self.client.ok_message) == 1)\r
+\r
+ def testNoPermission(self):\r
+ self.assertRaises(Unauthorised, RetireAction(self.client).handle)\r
+\r
+ def testDontRetireAdminOrAnonymous(self):\r
+ self.client.db.security.hasPermission=true\r
+ self.client.classname = 'user' \r
+ self.client.db.user.get = lambda a,b: 'admin'\r
+ self.assertRaises(ValueError, RetireAction(self.client).handle)\r
+\r
+class SearchActionTestCase(ActionTestCase):\r
+ def setUp(self):\r
+ ActionTestCase.setUp(self)\r
+ self.action = SearchAction(self.client)\r
+\r
+class StandardSearchActionTestCase(SearchActionTestCase):\r
+ def testNoPermission(self):\r
+ self.assertRaises(Unauthorised, self.action.handle)\r
+ \r
+ def testQueryName(self):\r
+ self.assertEqual(self.action.getQueryName(), '')\r
+\r
+ self.form.value.append(MiniFieldStorage('@queryname', 'foo'))\r
+ self.assertEqual(self.action.getQueryName(), 'foo')\r
+\r
+class FakeFilterVarsTestCase(SearchActionTestCase):\r
+ def setUp(self):\r
+ SearchActionTestCase.setUp(self)\r
+ self.client.db.classes.getprops = lambda: {'foo': hyperdb.Multilink('foo')}\r
+\r
+ def assertFilterEquals(self, expected):\r
+ self.action.fakeFilterVars()\r
+ self.assertEqual(self.form.getvalue('@filter'), expected)\r
+ \r
+ def testEmptyMultilink(self):\r
+ self.form.value.append(MiniFieldStorage('foo', ''))\r
+ self.form.value.append(MiniFieldStorage('foo', ''))\r
+\r
+ self.assertFilterEquals(None)\r
+\r
+ def testNonEmptyMultilink(self):\r
+ self.form.value.append(MiniFieldStorage('foo', ''))\r
+ self.form.value.append(MiniFieldStorage('foo', '1'))\r
+\r
+ self.assertFilterEquals('foo')\r
+\r
+ def testEmptyKey(self):\r
+ self.form.value.append(MiniFieldStorage('foo', ''))\r
+ self.assertFilterEquals(None)\r
+\r
+ def testStandardKey(self):\r
+ self.form.value.append(MiniFieldStorage('foo', '1'))\r
+ self.assertFilterEquals('foo')\r
+\r
+ def testStringKey(self):\r
+ self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()}\r
+ self.form.value.append(MiniFieldStorage('foo', 'hello'))\r
+ self.assertFilterEquals('foo')\r
+\r
+ def testTokenizedStringKey(self):\r
+ self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()}\r
+ self.form.value.append(MiniFieldStorage('foo', 'hello world'))\r
+ \r
+ self.assertFilterEquals('foo')\r
+\r
+ # The single value gets replaced with the tokenized list.\r
+ self.assertEqual([x.value for x in self.form['foo']], ['hello', 'world'])\r
+ \r
+def test_suite():\r
+ suite = unittest.TestSuite()\r
+ suite.addTest(unittest.makeSuite(RetireActionTestCase))\r
+ suite.addTest(unittest.makeSuite(StandardSearchActionTestCase))\r
+ suite.addTest(unittest.makeSuite(FakeFilterVarsTestCase))\r
+ suite.addTest(unittest.makeSuite(ShowActionTestCase)) \r
+ return suite\r
+\r
+if __name__ == '__main__':\r
+ runner = unittest.TextTestRunner()\r
+ unittest.main(testRunner=runner)\r
diff --git a/test/test_cgi.py b/test/test_cgi.py
index e4f26bb5f6385a5e5e34e2be2b4462347a7790b5..5af001145441735568100d921d2c055baf91cefa 100644 (file)
--- a/test/test_cgi.py
+++ b/test/test_cgi.py
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
-# $Id: test_cgi.py,v 1.21 2003-10-25 22:53:26 richard Exp $
+# $Id: test_cgi.py,v 1.22 2004-02-11 21:34:31 jlgijsbers Exp $
import unittest, os, shutil, errno, sys, difflib, cgi, re
from roundup.cgi import client
-from roundup.cgi.client import FormError
+from roundup.cgi.errors import FormError
+from roundup.cgi.FormParser import FormParser
from roundup import init, instance, password, hyperdb, date
NEEDS_INSTANCE = 1
# compile the labels re
classes = '|'.join(self.db.classes.keys())
- self.FV_SPECIAL = re.compile(client.Client.FV_LABELS%classes,
+ self.FV_SPECIAL = re.compile(FormParser.FV_LABELS%classes,
re.VERBOSE)
def parseForm(self, form, classname='test', nodeid=None):