From 2fda5db22e43dc9eea4d0284ca3358b560187daf Mon Sep 17 00:00:00 2001 From: stefan Date: Fri, 27 Feb 2009 17:46:47 +0000 Subject: [PATCH] * Add support for actions to XMLRPC interface. * Provide bridge so user actions may be executed either via CGI or XMLRPC. * Adjust XMLRPC tests to recent work. * Cleanup. git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/roundup/trunk@4175 57a73879-2fb5-44c3-a270-3262357dd7e2 --- roundup/actions.py | 68 ++++++++++++++++++++++++ roundup/cgi/actions.py | 45 +++++++++++++++- roundup/cgi/client.py | 4 +- roundup/cgi/exceptions.py | 16 ++---- roundup/exceptions.py | 15 ++++-- roundup/instance.py | 24 +++++++-- roundup/scripts/roundup_xmlrpc_server.py | 7 ++- roundup/xmlrpc.py | 24 +++++++-- test/test_xmlrpc.py | 57 ++++++++++++-------- 9 files changed, 207 insertions(+), 53 deletions(-) create mode 100644 roundup/actions.py diff --git a/roundup/actions.py b/roundup/actions.py new file mode 100644 index 0000000..0741aed --- /dev/null +++ b/roundup/actions.py @@ -0,0 +1,68 @@ +# +# Copyright (C) 2009 Stefan Seefeld +# All rights reserved. +# For license terms see the file COPYING.txt. +# + +from roundup.exceptions import * +from roundup import hyperdb +from roundup.i18n import _ + +class Action: + def __init__(self, db, translator): + self.db = db + self.translator = translator + + def handle(self, *args): + """Action handler procedure""" + raise NotImplementedError + + def execute(self, *args): + """Execute the action specified by this object.""" + + self.permission(*args) + return self.handle(*args) + + + def permission(self, *args): + """Check whether the user has permission to execute this action. + + If not, raise Unauthorised.""" + + pass + + + def gettext(self, msgid): + """Return the localized translation of msgid""" + return self.translator.gettext(msgid) + + + _ = gettext + + +class Retire(Action): + + def handle(self, designator): + + classname, itemid = hyperdb.splitDesignator(designator) + + # make sure we don't try to retire admin or anonymous + if (classname == 'user' and + self.db.user.get(itemid, 'username') in ('admin', 'anonymous')): + raise ValueError, self._( + 'You may not retire the admin or anonymous user') + + # do the retire + self.db.getclass(classname).retire(itemid) + self.db.commit() + + + def permission(self, designator): + + classname, itemid = hyperdb.splitDesignator(designator) + + if not self.db.security.hasPermission('Edit', self.db.getuid(), + classname=classname, itemid=itemid): + raise Unauthorised(self._('You do not have permission to ' + '%(action)s the %(classname)s class.')%info) + diff --git a/roundup/cgi/actions.py b/roundup/cgi/actions.py index e8d69ac..4f5a0b0 100755 --- a/roundup/cgi/actions.py +++ b/roundup/cgi/actions.py @@ -1,8 +1,7 @@ -#$Id: actions.py,v 1.73 2008-08-18 05:04:01 richard Exp $ - import re, cgi, StringIO, urllib, time, random, csv, codecs from roundup import hyperdb, token, date, password +from roundup.actions import Action as BaseAction from roundup.i18n import _ import roundup.exceptions from roundup.cgi import exceptions, templating @@ -991,4 +990,46 @@ class ExportCSVAction(Action): return '\n' + +class Bridge(BaseAction): + """Make roundup.actions.Action executable via CGI request. + + Using this allows users to write actions executable from multiple frontends. + CGI Form content is translated into a dictionary, which then is passed as + argument to 'handle()'. XMLRPC requests have to pass this dictionary + directly. + """ + + def __init__(self, *args): + + # As this constructor is callable from multiple frontends, each with + # different Action interfaces, we have to look at the arguments to + # figure out how to complete construction. + if (len(args) == 1 and + hasattr(args[0], '__class__') and + args[0].__class__.__name__ == 'Client'): + self.cgi = True + self.execute = self.execute_cgi + self.client = args[0] + self.form = self.client.form + else: + self.cgi = False + + def execute_cgi(self): + args = {} + for key in self.form.keys(): + args[key] = self.form.getvalue(key) + self.permission(args) + return self.handle(args) + + def permission(self, args): + """Raise Unauthorised if the current user is not allowed to execute + this action. Users may override this method.""" + + pass + + def handle(self, args): + + raise NotImplementedError + # vim: set filetype=python sts=4 sw=4 et si : diff --git a/roundup/cgi/client.py b/roundup/cgi/client.py index 0ea02f1..dad5c8e 100644 --- a/roundup/cgi/client.py +++ b/roundup/cgi/client.py @@ -382,7 +382,9 @@ class Client: self.determine_user() # Call the appropriate XML-RPC method. - handler = xmlrpc.RoundupDispatcher(self.db, self.userid, self.translator, + handler = xmlrpc.RoundupDispatcher(self.db, + self.instance.actions, + self.translator, allow_none=True) output = handler.dispatch(input) self.db.commit() diff --git a/roundup/cgi/exceptions.py b/roundup/cgi/exceptions.py index b7f33cb..c0cb886 100755 --- a/roundup/cgi/exceptions.py +++ b/roundup/cgi/exceptions.py @@ -1,20 +1,14 @@ -#$Id: exceptions.py,v 1.6 2004-11-18 14:10:27 a1s Exp $ -'''Exceptions for use in Roundup's web interface. -''' +"""Exceptions for use in Roundup's web interface. +""" __docformat__ = 'restructuredtext' +from roundup.exceptions import LoginError, Unauthorised import cgi class HTTPException(Exception): pass -class LoginError(HTTPException): - pass - -class Unauthorised(HTTPException): - pass - class Redirect(HTTPException): pass @@ -50,13 +44,13 @@ class SeriousError(Exception): escaped. """ def __str__(self): - return ''' + return """ Roundup issue tracker: An error has occurred

%s

-'''%cgi.escape(self.args[0]) +"""%cgi.escape(self.args[0]) # vim: set filetype=python sts=4 sw=4 et si : diff --git a/roundup/exceptions.py b/roundup/exceptions.py index a9ba2cf..629516d 100644 --- a/roundup/exceptions.py +++ b/roundup/exceptions.py @@ -1,11 +1,16 @@ -#$Id: exceptions.py,v 1.1 2004-03-26 00:44:11 richard Exp $ -'''Exceptions for use across all Roundup components. -''' +"""Exceptions for use across all Roundup components. +""" __docformat__ = 'restructuredtext' +class LoginError(Exception): + pass + +class Unauthorised(Exception): + pass + class Reject(Exception): - '''An auditor may raise this exception when the current create or set + """An auditor may raise this exception when the current create or set operation should be stopped. It is up to the specific interface invoking the create or set to @@ -13,7 +18,7 @@ class Reject(Exception): - mailgw will trap and ignore Reject for file attachments and messages - cgi will trap and present the exception in a nice format - ''' + """ pass class UsageError(ValueError): diff --git a/roundup/instance.py b/roundup/instance.py index 9aba2d0..127590b 100644 --- a/roundup/instance.py +++ b/roundup/instance.py @@ -15,19 +15,19 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: instance.py,v 1.37 2006-12-11 23:36:15 richard Exp $ -'''Tracker handling (open tracker). +"""Tracker handling (open tracker). Backwards compatibility for the old-style "imported" trackers. -''' +""" __docformat__ = 'restructuredtext' import os import sys from roundup import configuration, mailgw -from roundup import hyperdb, backends +from roundup import hyperdb, backends, actions from roundup.cgi import client, templating +from roundup.cgi import actions as cgi_actions class Vars: def __init__(self, vars): @@ -47,6 +47,7 @@ class Tracker: self.tracker_home = tracker_home self.optimize = optimize self.config = configuration.CoreConfig(tracker_home) + self.actions = {} self.cgi_actions = {} self.templating_utils = {} self.load_interfaces() @@ -182,7 +183,20 @@ class Tracker: return vars def registerAction(self, name, action): - self.cgi_actions[name] = action + + # The logic here is this: + # * if `action` derives from actions.Action, + # it is executable as a generic action. + # * if, moreover, it also derives from cgi.actions.Bridge, + # it may in addition be called via CGI + # * in all other cases we register it as a CGI action, without + # any check (for backward compatibility). + if issubclass(action, actions.Action): + self.actions[name] = action + if issubclass(action, cgi_actions.Bridge): + self.cgi_actions[name] = action + else: + self.cgi_actions[name] = action def registerUtil(self, name, function): self.templating_utils[name] = function diff --git a/roundup/scripts/roundup_xmlrpc_server.py b/roundup/scripts/roundup_xmlrpc_server.py index 44c626f..b5458e1 100644 --- a/roundup/scripts/roundup_xmlrpc_server.py +++ b/roundup/scripts/roundup_xmlrpc_server.py @@ -51,7 +51,10 @@ class RequestHandler(SimpleXMLRPCRequestHandler): if scheme.lower() == 'basic': decoded = base64.decodestring(challenge) - username, password = decoded.split(':') + if ':' in decoded: + username, password = decoded.split(':') + else: + username = decoded if not username: username = 'anonymous' db = tracker.open('admin') @@ -79,7 +82,7 @@ class RequestHandler(SimpleXMLRPCRequestHandler): tracker = self.get_tracker(tracker_name) db = self.authenticate(tracker) - instance = RoundupInstance(db, None) + instance = RoundupInstance(db, tracker.actions, None) self.server.register_instance(instance) SimpleXMLRPCRequestHandler.do_POST(self) except Unauthorised, message: diff --git a/roundup/xmlrpc.py b/roundup/xmlrpc.py index 4555287..03ef5a1 100644 --- a/roundup/xmlrpc.py +++ b/roundup/xmlrpc.py @@ -8,6 +8,7 @@ from roundup import hyperdb from roundup.cgi.exceptions import * from roundup.exceptions import UsageError from roundup.date import Date, Range, Interval +from roundup import actions from SimpleXMLRPCServer import * def translate(value): @@ -52,10 +53,10 @@ class RoundupInstance: """The RoundupInstance provides the interface accessible through the Python XMLRPC mapping.""" - def __init__(self, db, translator): + def __init__(self, db, actions, translator): self.db = db - self.userid = db.getuid() + self.actions = actions self.translator = translator def list(self, classname, propname=None): @@ -125,15 +126,30 @@ class RoundupInstance: raise UsageError, message + builtin_actions = {'retire': actions.Retire} + + def action(self, name, *args): + """""" + + if name in self.actions: + action_type = self.actions[name] + elif name in self.builtin_actions: + action_type = self.builtin_actions[name] + else: + raise Exception('action "%s" is not supported %s' % (name, ','.join(self.actions.keys()))) + action = action_type(self.db, self.translator) + return action.execute(*args) + + class RoundupDispatcher(SimpleXMLRPCDispatcher): """RoundupDispatcher bridges from cgi.client to RoundupInstance. It expects user authentication to be done.""" - def __init__(self, db, userid, translator, + def __init__(self, db, actions, translator, allow_none=False, encoding=None): SimpleXMLRPCDispatcher.__init__(self, allow_none, encoding) - self.register_instance(RoundupInstance(db, userid, translator)) + self.register_instance(RoundupInstance(db, actions, translator)) def dispatch(self, input): diff --git a/test/test_xmlrpc.py b/test/test_xmlrpc.py index 14e19b4..5409ea0 100644 --- a/test/test_xmlrpc.py +++ b/test/test_xmlrpc.py @@ -8,7 +8,7 @@ import unittest, os, shutil, errno, sys, difflib, cgi, re from roundup.cgi.exceptions import * from roundup import init, instance, password, hyperdb, date -from roundup.xmlrpc import RoundupServer +from roundup.xmlrpc import RoundupInstance from roundup.backends import list_backends import db_test_base @@ -32,8 +32,8 @@ class TestCase(unittest.TestCase): self.db.commit() self.db.close() - - self.server = RoundupServer(self.dirname) + self.db = self.instance.open('joe') + self.server = RoundupInstance(self.db, self.instance.actions, None) def tearDown(self): try: @@ -43,63 +43,74 @@ class TestCase(unittest.TestCase): def testAccess(self): # Retrieve all three users. - results = self.server.list('joe', 'random', 'user', 'id') + results = self.server.list('user', 'id') self.assertEqual(len(results), 3) # Obtain data for 'joe'. - results = self.server.display('joe', 'random', self.joeid) + results = self.server.display(self.joeid) self.assertEqual(results['username'], 'joe') self.assertEqual(results['realname'], 'Joe Random') def testChange(self): # Reset joe's 'realname'. - results = self.server.set('joe', 'random', self.joeid, - 'realname=Joe Doe') - results = self.server.display('joe', 'random', self.joeid, - 'realname') + results = self.server.set(self.joeid, 'realname=Joe Doe') + results = self.server.display(self.joeid, 'realname') self.assertEqual(results['realname'], 'Joe Doe') # check we can't change admin's details - self.assertRaises(Unauthorised, self.server.set, 'joe', 'random', - 'user1', 'realname=Joe Doe') + self.assertRaises(Unauthorised, self.server.set, 'user1', 'realname=Joe Doe') def testCreate(self): - results = self.server.create('joe', 'random', 'issue', 'title=foo') + results = self.server.create('issue', 'title=foo') issueid = 'issue' + results - results = self.server.display('joe', 'random', issueid, 'title') + results = self.server.display(issueid, 'title') self.assertEqual(results['title'], 'foo') def testFileCreate(self): - results = self.server.create('joe', 'random', 'file', 'content=hello\r\nthere') + results = self.server.create('file', 'content=hello\r\nthere') fileid = 'file' + results - results = self.server.display('joe', 'random', fileid, 'content') + results = self.server.display(fileid, 'content') self.assertEqual(results['content'], 'hello\r\nthere') - def testAuthUnknown(self): - # Unknown user (caught in XMLRPC frontend). - self.assertRaises(Unauthorised, self.server.list, - 'nobody', 'nobody', 'user', 'id') + def testAction(self): + # As this action requires special previledges, we temporarily switch + # to 'admin' + self.db.setCurrentUser('admin') + users_before = self.server.list('user') + try: + tmp = 'user' + self.db.user.create(username='tmp') + self.server.action('retire', tmp) + finally: + self.db.setCurrentUser('joe') + users_after = self.server.list('user') + self.assertEqual(users_before, users_after) def testAuthDeniedEdit(self): # Wrong permissions (caught by roundup security module). self.assertRaises(Unauthorised, self.server.set, - 'joe', 'random', 'user1', 'realname=someone') + 'user1', 'realname=someone') def testAuthDeniedCreate(self): self.assertRaises(Unauthorised, self.server.create, - 'joe', 'random', 'user', {'username': 'blah'}) + 'user', {'username': 'blah'}) def testAuthAllowedEdit(self): + self.db.setCurrentUser('admin') try: - self.server.set('admin', 'sekrit', 'user2', 'realname=someone') + self.server.set('user2', 'realname=someone') except Unauthorised, err: self.fail('raised %s'%err) + finally: + self.db.setCurrentUser('joe') def testAuthAllowedCreate(self): + self.db.setCurrentUser('admin') try: - self.server.create('admin', 'sekrit', 'user', 'username=blah') + self.server.create('user', 'username=blah') except Unauthorised, err: self.fail('raised %s'%err) + finally: + self.db.setCurrentUser('joe') def test_suite(): suite = unittest.TestSuite() -- 2.30.2