From a66875053f1014de3767937b1a918949c667ac74 Mon Sep 17 00:00:00 2001 From: Jeremy Kerr Date: Tue, 9 Sep 2008 09:28:33 +1000 Subject: [PATCH] Add 'update' method to pwclient This requires a new xmlrpc function, 'patch_set'. To do this, we need HTTP Authentication support, which means changing to a custom XMLRPC distpatcher that is aware of the Authorization: header. Signed-off-by: Jeremy Kerr --- apps/patchwork/bin/pwclient.py | 87 ++++++++++++++- apps/patchwork/urls.py | 1 + apps/patchwork/{ => views}/xmlrpc.py | 161 ++++++++++++++++++++++++++- apps/settings.py | 17 --- apps/urls.py | 4 - docs/INSTALL | 11 -- 6 files changed, 240 insertions(+), 41 deletions(-) rename apps/patchwork/{ => views}/xmlrpc.py (63%) diff --git a/apps/patchwork/bin/pwclient.py b/apps/patchwork/bin/pwclient.py index a50df73..0831086 100755 --- a/apps/patchwork/bin/pwclient.py +++ b/apps/patchwork/bin/pwclient.py @@ -26,6 +26,7 @@ import getopt import string import tempfile import subprocess +import base64 import ConfigParser # Default Patchwork remote XML-RPC server URL @@ -78,6 +79,24 @@ class Filter: """Return human-readable description of the filter.""" return str(self.d) +class BasicHTTPAuthTransport(xmlrpclib.Transport): + + def __init__(self, username = None, password = None): + self.username = username + self.password = password + xmlrpclib.Transport.__init__(self) + + def authenticated(self): + return self.username != None and self.password != None + + def send_host(self, connection, host): + xmlrpclib.Transport.send_host(self, connection, host) + if not self.authenticated(): + return + credentials = '%s:%s' % (self.username, self.password) + auth = 'Basic ' + base64.encodestring(credentials).strip() + connection.putheader('Authorization', auth) + def usage(): sys.stderr.write("Usage: %s [options]\n\n" % \ (os.path.basename(sys.argv[0]))) @@ -235,9 +254,39 @@ def action_apply(rpc, patch_id): sys.stderr.write("Error: No patch content found\n") sys.exit(1) +def action_update_patch(rpc, patch_id, state = None, commit = None): + patch = rpc.patch_get(patch_id) + if patch == {}: + sys.stderr.write("Error getting information on patch ID %d\n" % \ + patch_id) + sys.exit(1) + + params = {} + + if state: + state_id = state_id_by_name(rpc, state) + if state_id == 0: + sys.stderr.write("Error: No State found matching %s*\n" % state) + sys.exit(1) + params['state'] = state_id + + if commit: + params['commit_ref'] = commit + + success = False + try: + success = rpc.patch_set(patch_id, params) + except xmlrpclib.Fault, f: + sys.stderr.write("Error updating patch: %s\n" % f.faultString) + + if not success: + sys.stderr.write("Patch not updated\n") + +auth_actions = ['update'] + def main(): try: - opts, args = getopt.getopt(sys.argv[2:], 's:p:w:d:n:') + opts, args = getopt.getopt(sys.argv[2:], 's:p:w:d:n:c:') except getopt.GetoptError, err: print str(err) usage() @@ -252,6 +301,8 @@ def main(): submitter_str = "" delegate_str = "" project_str = "" + commit_str = "" + state_str = "" url = DEFAULT_URL config = ConfigParser.ConfigParser() @@ -266,13 +317,15 @@ def main(): for name, value in opts: if name == '-s': - filt.add("state", value) + state_str = value elif name == '-p': project_str = value elif name == '-w': submitter_str = value elif name == '-d': delegate_str = value + elif name == '-c': + commit_str = value elif name == '-n': try: filt.add("max_count", int(value)) @@ -287,11 +340,29 @@ def main(): sys.stderr.write("Too many arguments specified\n") usage() + (username, password) = (None, None) + transport = None + if action in auth_actions: + if config.has_option('auth', 'username') and \ + config.has_option('auth', 'password'): + + transport = BasicHTTPAuthTransport( \ + config.get('auth', 'username'), + config.get('auth', 'password')) + + else: + sys.stderr.write(("The %s action requires authentication, " + "but no username or password\nis configured\n") % action) + sys.exit(1) + if project_str: filt.add("project", project_str) + if state_str: + filt.add("state", state_str) + try: - rpc = xmlrpclib.Server(url) + rpc = xmlrpclib.Server(url, transport = transport) except: sys.stderr.write("Unable to connect to %s\n" % url) sys.exit(1) @@ -336,6 +407,16 @@ def main(): action_apply(rpc, patch_id) + elif action == 'update': + try: + patch_id = int(args[0]) + except: + sys.stderr.write("Invalid patch ID given\n") + sys.exit(1) + + action_update_patch(rpc, patch_id, state = state_str, + commit = commit_str) + else: sys.stderr.write("Unknown action '%s'\n" % action) usage() diff --git a/apps/patchwork/urls.py b/apps/patchwork/urls.py index f7c942a..ef1f2ad 100644 --- a/apps/patchwork/urls.py +++ b/apps/patchwork/urls.py @@ -58,6 +58,7 @@ urlpatterns = patterns('', if settings.ENABLE_XMLRPC: urlpatterns += patterns('', + (r'xmlrpc/$', 'patchwork.views.xmlrpc.xmlrpc'), (r'^pwclient.py/$', 'patchwork.views.pwclient'), (r'^project/(?P[^/]+)/pwclientrc/$', 'patchwork.views.pwclientrc'), diff --git a/apps/patchwork/xmlrpc.py b/apps/patchwork/views/xmlrpc.py similarity index 63% rename from apps/patchwork/xmlrpc.py rename to apps/patchwork/views/xmlrpc.py index fb64a7d..f493cf7 100644 --- a/apps/patchwork/xmlrpc.py +++ b/apps/patchwork/views/xmlrpc.py @@ -1,5 +1,5 @@ # Patchwork - automated patch tracking system -# Copyright (C) 2008 Nate Case +# Copyright (C) 2008 Jeremy Kerr # # This file is part of the Patchwork package. # @@ -17,14 +17,123 @@ # along with Patchwork; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # -# The XML-RPC interface provides a watered down, read-only interface to -# the Patchwork database. It's intended to be safe to export to the public -# Internet. A small subset of the object data is included, and the type -# of requests/queries you can do is limited by the methods -# that we export. +# Patchwork XMLRPC interface +# +from django.core.exceptions import ImproperlyConfigured +from SimpleXMLRPCServer import SimpleXMLRPCDispatcher +from django.http import HttpResponse, HttpResponseRedirect, \ + HttpResponseServerError +from django.conf import settings +from django.core import urlresolvers +from django.shortcuts import render_to_response +from django.contrib.auth import authenticate from patchwork.models import Patch, Project, Person, Bundle, State +import sys +import base64 +import xmlrpclib + +class PatchworkXMLRPCDispatcher(SimpleXMLRPCDispatcher): + def __init__(self): + if sys.version_info[:3] >= (2,5,): + SimpleXMLRPCDispatcher.__init__(self, allow_none=False, + encoding=None) + else: + SimpleXMLRPCDispatcher.__init__(self) + + # map of name => (auth, func) + self.func_map = {} + + + def register_function(self, fn, auth_required): + self.func_map[fn.__name__] = (auth_required, fn) + + + def _user_for_request(self, request): + if not request.META.has_key('HTTP_AUTHORIZATION'): + raise Exception("No authentication credentials given") + + str = request.META.get('HTTP_AUTHORIZATION').strip() + if not str.startswith('Basic '): + raise Exception("Authentication scheme not supported") + + str = str[len('Basic '):].strip() + + try: + decoded = base64.decodestring(str) + username, password = decoded.split(':', 1) + except: + raise Exception("Invalid authentication credentials") + + return authenticate(username = username, password = password) + + + def _dispatch(self, request, method, params): + if method not in self.func_map.keys(): + raise Exception('method "%s" is not supported' % method) + + auth_required, fn = self.func_map[method] + + if auth_required: + user = self._user_for_request(request) + if not user: + raise Exception("Invalid username/password") + + params = (user,) + params + + return fn(*params) + + def _marshaled_dispatch(self, request): + try: + params, method = xmlrpclib.loads(request.raw_post_data) + + response = self._dispatch(request, method, params) + # wrap response in a singleton tuple + response = (response,) + response = xmlrpclib.dumps(response, methodresponse=1, + allow_none=self.allow_none, encoding=self.encoding) + except xmlrpclib.Fault, fault: + response = xmlrpclib.dumps(fault, allow_none=self.allow_none, + encoding=self.encoding) + except: + # report exception back to server + response = xmlrpclib.dumps( + xmlrpclib.Fault(1, "%s:%s" % (sys.exc_type, sys.exc_value)), + encoding=self.encoding, allow_none=self.allow_none, + ) + + return response + +dispatcher = PatchworkXMLRPCDispatcher() + +# XMLRPC view function +def xmlrpc(request): + if request.method != 'POST': + return HttpResponseRedirect( + urlresolvers.reverse('patchwork.views.help', + kwargs = {'path': 'pwclient/'})) + + response = HttpResponse() + try: + ret = dispatcher._marshaled_dispatch(request) + response.write(ret) + except Exception, e: + return HttpResponseServerError() + + return response + +# decorator for XMLRPC methods. Setting login_required to true will call +# the decorated function with a non-optional user as the first argument. +def xmlrpc_method(login_required = False): + def wrap(f): + dispatcher.register_function(f, login_required) + return f + + return wrap + + + # We allow most of the Django field lookup types for remote queries LOOKUP_TYPES = ["iexact", "contains", "icontains", "gt", "gte", "lt", "in", "startswith", "istartswith", "endswith", @@ -100,10 +209,12 @@ def state_to_dict(obj): # Public XML-RPC methods ####################################################################### +@xmlrpc_method(False) def pw_rpc_version(): """Return Patchwork XML-RPC interface version.""" return 1 +@xmlrpc_method(False) def project_list(search_str="", max_count=0): """Get a list of projects matching the given filters.""" try: @@ -119,6 +230,7 @@ def project_list(search_str="", max_count=0): except: return [] +@xmlrpc_method(False) def project_get(project_id): """Return structure for the given project ID.""" try: @@ -127,6 +239,7 @@ def project_get(project_id): except: return {} +@xmlrpc_method(False) def person_list(search_str="", max_count=0): """Get a list of Person objects matching the given filters.""" try: @@ -144,6 +257,7 @@ def person_list(search_str="", max_count=0): except: return [] +@xmlrpc_method(False) def person_get(person_id): """Return structure for the given person ID.""" try: @@ -152,6 +266,7 @@ def person_get(person_id): except: return {} +@xmlrpc_method(False) def patch_list(filter={}): """Get a list of patches matching the given filters.""" try: @@ -210,6 +325,7 @@ def patch_list(filter={}): except: return [] +@xmlrpc_method(False) def patch_get(patch_id): """Return structure for the given patch ID.""" try: @@ -218,6 +334,7 @@ def patch_get(patch_id): except: return {} +@xmlrpc_method(False) def patch_get_mbox(patch_id): """Return mbox string for the given patch ID.""" try: @@ -226,6 +343,7 @@ def patch_get_mbox(patch_id): except: return "" +@xmlrpc_method(False) def patch_get_diff(patch_id): """Return diff for the given patch ID.""" try: @@ -234,6 +352,36 @@ def patch_get_diff(patch_id): except: return "" +@xmlrpc_method(True) +def patch_set(user, patch_id, params): + """Update a patch with the key,value pairs in params. Only some parameters + can be set""" + try: + ok_params = ['state', 'commit_ref', 'archived'] + + patch = Patch.objects.get(id = patch_id) + + if not patch.is_editable(user): + raise Exception('No permissions to edit this patch') + + for (k, v) in params.iteritems(): + if k not in ok_params: + continue + + if k == 'state': + patch.state = State.objects.get(id = v) + + else: + setattr(patch, k, v) + + patch.save() + + return True + + except: + raise + +@xmlrpc_method(False) def state_list(search_str="", max_count=0): """Get a list of state structures matching the given search string.""" try: @@ -249,6 +397,7 @@ def state_list(search_str="", max_count=0): except: return [] +@xmlrpc_method(False) def state_get(state_id): """Return structure for the given state ID.""" try: diff --git a/apps/settings.py b/apps/settings.py index f70ac2a..83aeeb4 100644 --- a/apps/settings.py +++ b/apps/settings.py @@ -101,25 +101,8 @@ ACCOUNT_ACTIVATION_DAYS = 7 # Set to True to enable the Patchwork XML-RPC interface ENABLE_XMLRPC = False -XMLRPC_METHODS = ( - # List methods to be exposed in the form (, ,) - ('patchwork.xmlrpc.pw_rpc_version', 'pw_rpc_version',), - ('patchwork.xmlrpc.patch_list', 'patch_list',), - ('patchwork.xmlrpc.patch_get', 'patch_get',), - ('patchwork.xmlrpc.patch_get_mbox', 'patch_get_mbox',), - ('patchwork.xmlrpc.patch_get_diff', 'patch_get_diff',), - ('patchwork.xmlrpc.project_list', 'project_list',), - ('patchwork.xmlrpc.project_get', 'project_get',), - ('patchwork.xmlrpc.person_list', 'person_list',), - ('patchwork.xmlrpc.person_get', 'person_get',), - ('patchwork.xmlrpc.state_list', 'state_list',), - ('patchwork.xmlrpc.state_get', 'state_get',), -) - try: from local_settings import * - if ENABLE_XMLRPC: - INSTALLED_APPS = INSTALLED_APPS + ('django_xmlrpc',) except ImportError, ex: import sys sys.stderr.write(\ diff --git a/apps/urls.py b/apps/urls.py index 9886fd7..5c4ac57 100644 --- a/apps/urls.py +++ b/apps/urls.py @@ -49,7 +49,3 @@ urlpatterns = patterns('', {'document_root': '/srv/patchwork/htdocs/images'}), ) -if settings.ENABLE_XMLRPC: - urlpatterns += patterns('', - (r'xmlrpc/$', 'django_xmlrpc.views.handle_xmlrpc'), - ) diff --git a/docs/INSTALL b/docs/INSTALL index cba69d5..8f3aab9 100644 --- a/docs/INSTALL +++ b/docs/INSTALL @@ -62,17 +62,6 @@ in brackets): cd ../../apps ln -s ../lib/packages/django-registration ./registration - (OPTIONAL) If you want to enable the Patchwork XML-RPC interface, - which is required for pwclient to work, you'll need to set up the - django_xmlrpc package: - - cd lib/packages/ - wget \ - http://django-xmlrpc.googlecode.com/files/django_xmlrpc-0.1.tar.gz - tar -zxf django_xmlrpc-0.1.tar.gz - cd ../../apps - ln -s ../lib/packages/django_xmlrpc ./django_xmlrpc - The settings.py file contains default settings for patchwork, you'll need to configure settings for your own setup. -- 2.39.2