From b4a76c697afb9a0004a0cef484a9cd06b9057038 Mon Sep 17 00:00:00 2001 From: jlgijsbers Date: Wed, 11 Feb 2004 21:34:31 +0000 Subject: [PATCH] Move out parts of client.py to new modules: * 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 --- roundup/cgi/actions.py | 745 ++++++++++++++++++++ roundup/cgi/client.py | 1321 +----------------------------------- roundup/cgi/exceptions.py | 32 + roundup/cgi/form_parser.py | 536 +++++++++++++++ test/test_actions.py | 144 ++++ test/test_cgi.py | 7 +- 6 files changed, 1484 insertions(+), 1301 deletions(-) create mode 100755 roundup/cgi/actions.py create mode 100755 roundup/cgi/exceptions.py create mode 100755 roundup/cgi/form_parser.py create mode 100755 test/test_actions.py diff --git a/roundup/cgi/actions.py b/roundup/cgi/actions.py new file mode 100755 index 0000000..994a96f --- /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 '
'.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 672f899..ace158e 100644 --- a/roundup/cgi/client.py +++ b/roundup/cgi/client.py @@ -1,51 +1,20 @@ -# $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 @@ -122,30 +91,6 @@ class Client: 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)| - (?P[@:]file)| - ( - ((?P%s)(?P[-\d]+))? # optional leading designator - ((?P[@:]required$)| # :required - ( - ( - (?P[@:]add[@:])| # :add: - (?P[@:]remove[@:])| # :remove: - (?P[@:]confirm[@:])| # :confirm: - (?P[@:]link[@:])| # :link: - ([@:]) # just a separator - )? - (?P[^@:]+) # - ) - ) - ) - )$''' - # Note: index page stuff doesn't appear here: # columns, sort, sortdir, filter, group, groupdir, search_text, # pagesize, startwith @@ -565,17 +510,17 @@ class Client: # 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. @@ -592,17 +537,15 @@ class Client: 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: @@ -677,1230 +620,12 @@ class Client: 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 '
'.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, values are variable, "@" may be - either ":" or "@", and other text "required" is fixed. - - Most properties are specified as form variables: - - - - property on the current context item - - "@" - - property on the indicated item (for editing related - information) - - Designators name a specific item of a class. - - - - Name an existing item of class . - - "-" - - Name the th new item of class . If the form - submission is successful, a new item of 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 is missing, the properties are - for the current context item. When is - present, they are for the item specified by - . - - The "@required" specifier must come before any of the - properties it refers to are assigned in the form. - - @remove@=id(s) or @add@=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@= - 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 new file mode 100755 index 0000000..1ae37e1 --- /dev/null +++ b/roundup/cgi/exceptions.py @@ -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 new file mode 100755 index 0000000..ded96e4 --- /dev/null +++ b/roundup/cgi/form_parser.py @@ -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)| + (?P[@:]file)| + ( + ((?P%s)(?P[-\d]+))? # optional leading designator + ((?P[@:]required$)| # :required + ( + ( + (?P[@:]add[@:])| # :add: + (?P[@:]remove[@:])| # :remove: + (?P[@:]confirm[@:])| # :confirm: + (?P[@:]link[@:])| # :link: + ([@:]) # just a separator + )? + (?P[^@:]+) # + ) + ) + ) + )$''' + + 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, values are variable, "@" may be + either ":" or "@", and other text "required" is fixed. + + Most properties are specified as form variables: + + + - property on the current context item + + "@" + - property on the indicated item (for editing related + information) + + Designators name a specific item of a class. + + + + Name an existing item of class . + + "-" + + Name the th new item of class . If the form + submission is successful, a new item of 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 is missing, the properties are + for the current context item. When is + present, they are for the item specified by + . + + The "@required" specifier must come before any of the + properties it refers to are assigned in the form. + + @remove@=id(s) or @add@=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@= + 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 new file mode 100755 index 0000000..10cd733 --- /dev/null +++ b/test/test_actions.py @@ -0,0 +1,144 @@ +import unittest +from cgi import FieldStorage, MiniFieldStorage + +from roundup import hyperdb +from roundup.cgi.actions import * +from roundup.cgi.exceptions import Redirect, Unauthorised + +class MockNull: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def __call__(self, *args, **kwargs): return MockNull() + def __getattr__(self, name): + # This allows assignments which assume all intermediate steps are Null + # objects if they don't exist yet. + # + # For example (with just 'client' defined): + # + # client.db.config.TRACKER_WEB = 'BASE/' + setattr(self, name, MockNull()) + return getattr(self, name) + + def __getitem__(self, key): return self + def __nonzero__(self): return 0 + def __str__(self): return '' + +def true(*args, **kwargs): + return 1 + +class ActionTestCase(unittest.TestCase): + def setUp(self): + self.form = FieldStorage() + self.client = MockNull() + self.client.form = self.form + +class ShowActionTestCase(ActionTestCase): + def assertRaisesMessage(self, exception, callable, message, *args, **kwargs): + try: + callable(*args, **kwargs) + except exception, msg: + self.assertEqual(str(msg), message) + else: + if hasattr(excClass,'__name__'): excName = excClass.__name__ + else: excName = str(excClass) + raise self.failureException, excName + + def testShowAction(self): + self.client.db.config.TRACKER_WEB = 'BASE/' + + action = ShowAction(self.client) + self.assertRaises(ValueError, action.handle) + + self.form.value.append(MiniFieldStorage('@type', 'issue')) + self.assertRaisesMessage(Redirect, action.handle, 'BASE/issue') + + self.form.value.append(MiniFieldStorage('@number', '1')) + self.assertRaisesMessage(Redirect, action.handle, 'BASE/issue1') + +class RetireActionTestCase(ActionTestCase): + def testRetireAction(self): + self.client.db.security.hasPermission = true + self.client.ok_message = [] + RetireAction(self.client).handle() + self.assert_(len(self.client.ok_message) == 1) + + def testNoPermission(self): + self.assertRaises(Unauthorised, RetireAction(self.client).handle) + + def testDontRetireAdminOrAnonymous(self): + self.client.db.security.hasPermission=true + self.client.classname = 'user' + self.client.db.user.get = lambda a,b: 'admin' + self.assertRaises(ValueError, RetireAction(self.client).handle) + +class SearchActionTestCase(ActionTestCase): + def setUp(self): + ActionTestCase.setUp(self) + self.action = SearchAction(self.client) + +class StandardSearchActionTestCase(SearchActionTestCase): + def testNoPermission(self): + self.assertRaises(Unauthorised, self.action.handle) + + def testQueryName(self): + self.assertEqual(self.action.getQueryName(), '') + + self.form.value.append(MiniFieldStorage('@queryname', 'foo')) + self.assertEqual(self.action.getQueryName(), 'foo') + +class FakeFilterVarsTestCase(SearchActionTestCase): + def setUp(self): + SearchActionTestCase.setUp(self) + self.client.db.classes.getprops = lambda: {'foo': hyperdb.Multilink('foo')} + + def assertFilterEquals(self, expected): + self.action.fakeFilterVars() + self.assertEqual(self.form.getvalue('@filter'), expected) + + def testEmptyMultilink(self): + self.form.value.append(MiniFieldStorage('foo', '')) + self.form.value.append(MiniFieldStorage('foo', '')) + + self.assertFilterEquals(None) + + def testNonEmptyMultilink(self): + self.form.value.append(MiniFieldStorage('foo', '')) + self.form.value.append(MiniFieldStorage('foo', '1')) + + self.assertFilterEquals('foo') + + def testEmptyKey(self): + self.form.value.append(MiniFieldStorage('foo', '')) + self.assertFilterEquals(None) + + def testStandardKey(self): + self.form.value.append(MiniFieldStorage('foo', '1')) + self.assertFilterEquals('foo') + + def testStringKey(self): + self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()} + self.form.value.append(MiniFieldStorage('foo', 'hello')) + self.assertFilterEquals('foo') + + def testTokenizedStringKey(self): + self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()} + self.form.value.append(MiniFieldStorage('foo', 'hello world')) + + self.assertFilterEquals('foo') + + # The single value gets replaced with the tokenized list. + self.assertEqual([x.value for x in self.form['foo']], ['hello', 'world']) + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(RetireActionTestCase)) + suite.addTest(unittest.makeSuite(StandardSearchActionTestCase)) + suite.addTest(unittest.makeSuite(FakeFilterVarsTestCase)) + suite.addTest(unittest.makeSuite(ShowActionTestCase)) + return suite + +if __name__ == '__main__': + runner = unittest.TextTestRunner() + unittest.main(testRunner=runner) diff --git a/test/test_cgi.py b/test/test_cgi.py index e4f26bb..5af0011 100644 --- a/test/test_cgi.py +++ b/test/test_cgi.py @@ -8,12 +8,13 @@ # 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 @@ -89,7 +90,7 @@ class FormTestCase(unittest.TestCase): # 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): -- 2.30.2