From c3436ec37009b1d1bfadf18806b1252b634a446b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mois=C3=A9s=20L=C3=B3pez?= Date: Mon, 22 May 2017 11:12:20 -0500 Subject: [PATCH] [ADD] webhook: Add standard webhook odoo module --- webhook/README.rst | 143 +++++++++++++++++++ webhook/__init__.py | 7 + webhook/__openerp__.py | 30 ++++ webhook/controllers/__init__.py | 5 + webhook/controllers/main.py | 44 ++++++ webhook/data/webhook_data.xml | 16 +++ webhook/demo/webhook_demo.xml | 17 +++ webhook/models/__init__.py | 5 + webhook/models/webhook.py | 205 +++++++++++++++++++++++++++ webhook/security/ir.model.access.csv | 3 + webhook/tests/__init__.py | 5 + webhook/tests/test_webhook_post.py | 124 ++++++++++++++++ webhook/views/webhook_views.xml | 44 ++++++ 13 files changed, 648 insertions(+) create mode 100644 webhook/README.rst create mode 100644 webhook/__init__.py create mode 100644 webhook/__openerp__.py create mode 100644 webhook/controllers/__init__.py create mode 100644 webhook/controllers/main.py create mode 100644 webhook/data/webhook_data.xml create mode 100644 webhook/demo/webhook_demo.xml create mode 100644 webhook/models/__init__.py create mode 100644 webhook/models/webhook.py create mode 100644 webhook/security/ir.model.access.csv create mode 100644 webhook/tests/__init__.py create mode 100644 webhook/tests/test_webhook_post.py create mode 100644 webhook/views/webhook_views.xml diff --git a/webhook/README.rst b/webhook/README.rst new file mode 100644 index 000000000..568b33e38 --- /dev/null +++ b/webhook/README.rst @@ -0,0 +1,143 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +======= +Webhook +======= + +Module to receive .. _global webhooks: https://en.wikipedia.org/wiki/Webhook events. +This module invoke methods to process webhook events. + + +Configuration +============= + +You will need create a new module to add your logic to process the events with methods called: +*def run_CONSUMER_EVENT\** +Example with gihub consumer and push event. + +.. code-block:: python + + @api.one + def run_github_push_task(self): + # You will have all request data in + # variable: self.env.request + pass + + +Where CONSUMER is the name of you webhook consumer. e.g. github (Extract from field *name* of *webhook* model) +Where EVENT is the name of the event from webhook *request* data. +Where *\** is your particular method to process this event. + +To configure a new webhook you need add all ip or subnet address (with *ip/integer*) owned by your webhook consumer in webhook.address model as data. + +Example with github: + +.. code-block:: xml + + + + 192.30.252.0/22 + + + + +You need to add a python code to extract event name from webhook request info into `python_code_get_event` field of webhook model. +You can get all full data of request webhook from variable `request` +Example with github: + +.. code-block:: xml + + + + github + request.httprequest.headers.get('X-Github-Event') + + + +Full example of create a new webhook configuration data. + +.. code-block:: xml + + + + + + + + github + request.httprequest.headers.get('X-Github-Event') + + + + 192.30.252.0/22 + + + + + + + +.. figure:: path/to/local/image.png + :alt: alternative description + :width: 600 px + +Usage +===== + +To use this module, you need to: + +#. Go to your customer webhook configuration from 3rd-party applications + and use the odoo webhook url HOST/webhook/NAME_WEBHOOK + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/{repo_id}/{branch} + +.. repo_id is available in https://github.com/OCA/maintainer-tools/blob/master/tools/repos_with_ids.txt +.. branch is "8.0" for example + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Moisés López + +Funders +------- + +The development of this module has been financially supported by: + +* Vauxoo + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/webhook/__init__.py b/webhook/__init__.py new file mode 100644 index 000000000..b790d3798 --- /dev/null +++ b/webhook/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Vauxoo - https://www.vauxoo.com/ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models +from . import controllers +from . import tests diff --git a/webhook/__openerp__.py b/webhook/__openerp__.py new file mode 100644 index 000000000..e8d324187 --- /dev/null +++ b/webhook/__openerp__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Vauxoo - https://www.vauxoo.com/ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + 'name': 'Webhook', + 'version': '8.0.1.0.0', + 'author': 'Vauxoo, Odoo Community Association (OCA)', + 'category': 'Server Tools', + 'website': 'https://www.vauxoo.com', + 'license': 'AGPL-3', + 'depends': [ + 'web', + ], + 'external_dependencies': { + 'python': [ + 'ipaddress', + 'requests', + ], + }, + 'data': [ + 'security/ir.model.access.csv', + 'views/webhook_views.xml', + 'data/webhook_data.xml', + ], + 'demo': [ + 'demo/webhook_demo.xml', + ], + 'installable': True, + 'auto_install': False, +} diff --git a/webhook/controllers/__init__.py b/webhook/controllers/__init__.py new file mode 100644 index 000000000..c860173db --- /dev/null +++ b/webhook/controllers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Vauxoo - https://www.vauxoo.com/ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import main diff --git a/webhook/controllers/main.py b/webhook/controllers/main.py new file mode 100644 index 000000000..546130bad --- /dev/null +++ b/webhook/controllers/main.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Vauxoo - https://www.vauxoo.com/ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import pprint + +from openerp.addons.web import http +from openerp.http import request +from openerp import SUPERUSER_ID, exceptions +from openerp.tools.translate import _ + + +class WebhookController(http.Controller): + + @http.route(['/webhook/'], type='json', + auth='none', method=['POST']) + def webhook(self, webhook_name, **post): + ''' + :params string webhook_name: Name of webhook to use + Webhook odoo controller to receive json request and send to + driver method. + You will need create your webhook with http://0.0.0.0:0000/webhook + NOTE: Important use --db-filter params in odoo start. + ''' + # Deprecated by webhook_name dynamic name + # webhook = webhook_registry.search_with_request( + # cr, SUPERUSER_ID, request, context=context) + webhook = request.env['webhook'].with_env( + request.env(user=SUPERUSER_ID)).search( + [('name', '=', webhook_name)], limit=1) + # TODO: Add security by secret string or/and ip consumer + if not webhook: + remote_addr = '' + if hasattr(request, 'httprequest'): + if hasattr(request.httprequest, 'remote_addr'): + remote_addr = request.httprequest.remote_addr + raise exceptions.ValidationError(_( + 'webhook consumer [%s] from remote address [%s] ' + 'not found jsonrequest [%s]' % ( + webhook_name, + remote_addr, + pprint.pformat(request.jsonrequest)[:450] + ))) + webhook.run_webhook(request) diff --git a/webhook/data/webhook_data.xml b/webhook/data/webhook_data.xml new file mode 100644 index 000000000..4d7deef0d --- /dev/null +++ b/webhook/data/webhook_data.xml @@ -0,0 +1,16 @@ + + + + + + + github + request.httprequest.headers.get('X-Github-Event') + + + 192.30.252.0/22 + + + + + diff --git a/webhook/demo/webhook_demo.xml b/webhook/demo/webhook_demo.xml new file mode 100644 index 000000000..f1e222483 --- /dev/null +++ b/webhook/demo/webhook_demo.xml @@ -0,0 +1,17 @@ + + + + + + wehook_test + request.httprequest.headers.get("X-Webhook-Test-Event") + request.httprequest.headers.get("X-Webhook-Test-Address") + + + + 127.0.0.1 + + + + + diff --git a/webhook/models/__init__.py b/webhook/models/__init__.py new file mode 100644 index 000000000..fa1dca815 --- /dev/null +++ b/webhook/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Vauxoo - https://www.vauxoo.com/ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import webhook diff --git a/webhook/models/webhook.py b/webhook/models/webhook.py new file mode 100644 index 000000000..c47aad59d --- /dev/null +++ b/webhook/models/webhook.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Vauxoo - https://www.vauxoo.com/ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import traceback + +from openerp import api, exceptions, fields, models, tools +from openerp.tools.safe_eval import safe_eval +from openerp.tools.translate import _ + +_logger = logging.getLogger(__name__) + +try: + import ipaddress +except ImportError as err: + _logger.debug(err) + + +class WebhookAddress(models.Model): + _name = 'webhook.address' + + name = fields.Char( + 'IP or Network Address', + required=True, + help='IP or network address of your consumer webhook:\n' + 'ip address e.g.: 10.10.0.8\n' + 'network address e.g. of: 10.10.0.8/24', + ) + webhook_id = fields.Many2one( + 'webhook', 'Webhook', required=True, ondelete='cascade') + + +class Webhook(models.Model): + _name = 'webhook' + + name = fields.Char( + 'Consumer name', + required=True, + help='Name of your consumer webhook. ' + 'This name will be used in named of event methods') + address_ids = fields.One2many( + 'webhook.address', 'webhook_id', 'IP or Network Address', + required=True, + help='This address will be filter to know who is ' + 'consumer webhook') + python_code_get_event = fields.Text( + 'Get event', + required=True, + help='Python code to get event from request data.\n' + 'You have object.env.request variable with full ' + 'webhook request.', + default='# You can use object.env.request variable ' + 'to get full data of webhook request.\n' + '# Example:\n#request.httprequest.' + 'headers.get("X-Github-Event")', + ) + python_code_get_ip = fields.Text( + 'Get IP', + required=True, + help='Python code to get remote IP address ' + 'from request data.\n' + 'You have object.env.request variable with full ' + 'webhook request.', + default='# You can use object.env.request variable ' + 'to get full data of webhook request.\n' + '# Example:\n' + '#object.env.request.httprequest.remote_addr' + '\nrequest.httprequest.remote_addr', + + ) + active = fields.Boolean(default=True) + + @api.multi + def process_python_code(self, python_code, request=None): + """ + Execute a python code with eval. + :param string python_code: Python code to process + :param object request: Request object with data of json + and http request + :return: Result of process python code. + """ + self.ensure_one() + res = None + eval_dict = { + 'user': self.env.user, + 'object': self, + 'request': request, + # copy context to prevent side-effects of eval + 'context': dict(self.env.context), + } + try: + res = safe_eval(python_code, eval_dict) + except BaseException: + error = tools.ustr(traceback.format_exc()) + _logger.debug( + 'python_code "%s" with dict [%s] error [%s]', + python_code, eval_dict, error) + if isinstance(res, basestring): + res = tools.ustr(res) + return res + + @api.model + def search_with_request(self, request): + """ + Method to search of all webhook + and return only one that match with remote address + and range of address. + :param object request: Request object with data of json + and http request + :return: object of webhook found or + if not found then return False + """ + for webhook in self.search([('active', '=', True)]): + remote_address = webhook.process_python_code( + webhook.python_code_get_ip, request) + if not remote_address: + continue + if webhook.is_address_range(remote_address): + return webhook + return False + + @api.multi + def is_address_range(self, remote_address): + """ + Check if a remote IP address is in range of one + IP or network address of current object data. + :param string remote_address: Remote IP address + :return: if remote address match then return True + else then return False + """ + self.ensure_one() + for address in self.address_ids: + ipn = ipaddress.ip_network(u'' + address.name) + hosts = [host.exploded for host in ipn.hosts()] + hosts.append(address.name) + if remote_address in hosts: + return True + return False + + @api.model + def get_event_methods(self, event_method_base): + """ + List all methods of current object that start with base name. + e.g. if exists methods called 'x1', 'x2' + and other ones called 'y1', 'y2' + if you have event_method_base='x' + Then will return ['x1', 'x2'] + :param string event_method_base: Name of method event base + returns: List of methods with that start wtih method base + """ + # TODO: Filter just callable attributes + return sorted( + attr for attr in dir(self) if attr.startswith( + event_method_base) + ) + + @api.model + def get_ping_events(self): + """ + List all name of event type ping. + This event is a dummy event just to + know if a provider is working. + :return: List with names of ping events + """ + return ['ping'] + + @api.multi + def run_webhook(self, request): + """ + Run methods to process a webhook event. + Get all methods with base name + 'run_CONSUMER_EVENT*' + Invoke all methods found. + :param object request: Request object with data of json + and http request + :return: True + """ + self.ensure_one() + event = self.process_python_code( + self.python_code_get_event, request) + if not event: + raise exceptions.ValidationError(_( + 'event is not defined')) + method_event_name_base = \ + 'run_' + self.name + \ + '_' + event + methods_event_name = self.get_event_methods(method_event_name_base) + if not methods_event_name: + # if is a 'ping' event then return True + # because the request is received fine. + if event in self.get_ping_events(): + return True + raise exceptions.ValidationError(_( + 'Not defined methods "%s" yet' % ( + method_event_name_base))) + self.env.request = request + for method_event_name in methods_event_name: + method = getattr(self, method_event_name) + res_method = method() + if isinstance(res_method, list) and len(res_method) == 1: + if res_method[0] is NotImplemented: + _logger.debug( + 'Not implemented method "%s" yet', method_event_name) + return True diff --git a/webhook/security/ir.model.access.csv b/webhook/security/ir.model.access.csv new file mode 100644 index 000000000..93564dd8e --- /dev/null +++ b/webhook/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_webhook_address,access_webhook_address,model_webhook_address,base.group_system,1,1,1,1 +access_webhook,access_webhook,model_webhook,base.group_system,1,1,1,1 diff --git a/webhook/tests/__init__.py b/webhook/tests/__init__.py new file mode 100644 index 000000000..87ff26675 --- /dev/null +++ b/webhook/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Vauxoo - https://www.vauxoo.com/ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_webhook_post diff --git a/webhook/tests/test_webhook_post.py b/webhook/tests/test_webhook_post.py new file mode 100644 index 000000000..c1adf30b7 --- /dev/null +++ b/webhook/tests/test_webhook_post.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Vauxoo - https://www.vauxoo.com/ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +import requests + +from openerp.tests.common import HttpCase +from openerp import api, exceptions, tools, models +from openerp.tools.translate import _ + + +HOST = '127.0.0.1' +PORT = tools.config['xmlrpc_port'] + + +class Webhook(models.Model): + _inherit = 'webhook' + + @api.multi + def run_wehook_test_get_foo(self): + """ + This method is just to test webhook. + This needs receive a json request with + next json values: {'foo': 'bar'} + If value is different will raise a error. + """ + self.ensure_one() + if self.env.request.jsonrequest['foo'] != 'bar': + raise exceptions.ValidationError(_("Wrong value received")) + + +class FakeHttpRequest(object): + remote_address = None + headers = {} + + +class FakeRequest(object): + def __init__(self, **args): + self.httprequest = FakeHttpRequest() + + +class TestWebhookPost(HttpCase): + def setUp(self): + super(TestWebhookPost, self).setUp() + self.webhook = self.env['webhook'] + self.url_base = "http://%s:%s" % (HOST, PORT) + self.url = self.get_webhook_url() + + def get_webhook_url(self, url='/webhook', + webhook_name="wehook_test"): + """ + :param string url: Full url of last url of webhook to use. + If you use a full url will return url + plus session_id + default: /webhook + :param string webhook_name: Name of webhook to process + default: webhook_test + :return: url with + http://IP:PORT/webhook/webhook_name?session_id=### + """ + webhook_name = webhook_name.replace('/', '') + if url.startswith('/'): + url = self.url_base + url + '/' + webhook_name + url += '?session_id=' + self.session_id + return url + + def post_webhook_event(self, event, url, data, remote_ip=None, + headers=None, params=None): + """ + :param string event String: Name of webhook event. + :param string url: Full url of webhook services. + :param dict data: Payload data of request. + :param string remote_ip: Remote IP of webhook to set in + test variable. + :param dict headers: Request headers with main data. + :param dict params: Extra values to send to webhook. + """ + if headers is None: + headers = {} + if remote_ip is None: + remote_ip = '127.0.0.1' + headers.update({ + 'X-Webhook-Test-Event': event, + 'X-Webhook-Test-Address': remote_ip, + }) + headers.setdefault('accept', 'application/json') + headers.setdefault('content-type', 'application/json') + payload = json.dumps(data) + response = requests.request( + "POST", url, data=payload, + headers=headers, params=params) + return response.json() + + def test_webhook_ping(self): + """ + Test to check that 'ping' generic method work fine! + 'ping' event don't need to add it in inherit class. + """ + json_response = self.post_webhook_event( + 'ping', self.url, {}) + has_error = json_response.get('error', False) + self.assertEqual( + has_error, False, 'Error in webhook ping test!') + + def test_webhook_get_foo(self): + """ + Test to check that 'get_foo' event from 'webhook_test' + work fine! + This event is defined in inherit method of test. + """ + json_response = self.post_webhook_event( + 'get_foo', self.url, {'foo': 'bar'}) + self.assertEqual( + json_response.get('error', False), False, + 'Error in webhook get foo test!.') + + def test_webhook_search_with_request(self): + """Test to check that 'search_with_request' method works!""" + fake_req = FakeRequest() + fake_req.httprequest.headers['X-Webhook-Test-Address'] = '127.0.0.1' + wh = self.webhook.search_with_request(fake_req) + self.assertEqual(wh.id, self.env.ref('webhook.webhook_test').id, + "Search webhook from request IP info is not working") diff --git a/webhook/views/webhook_views.xml b/webhook/views/webhook_views.xml new file mode 100644 index 000000000..97e8699b5 --- /dev/null +++ b/webhook/views/webhook_views.xml @@ -0,0 +1,44 @@ + + + + + + view.webhook.form + webhook + + +
+ + + + + + + + + + +
+
+
+ + view.webhook.tree + webhook + + + + + + + + + + Webhook + webhook + form + tree,form + + + +
+