Moisés López
8 years ago
committed by
Jairo Llopis
13 changed files with 648 additions and 0 deletions
-
143webhook/README.rst
-
7webhook/__init__.py
-
30webhook/__openerp__.py
-
5webhook/controllers/__init__.py
-
44webhook/controllers/main.py
-
16webhook/data/webhook_data.xml
-
17webhook/demo/webhook_demo.xml
-
5webhook/models/__init__.py
-
205webhook/models/webhook.py
-
3webhook/security/ir.model.access.csv
-
5webhook/tests/__init__.py
-
124webhook/tests/test_webhook_post.py
-
44webhook/views/webhook_views.xml
@ -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 |
|||
|
|||
<!--webhook github data of remote address--> |
|||
<record model="webhook.address" id="webhook_address_github"> |
|||
<field name="name">192.30.252.0/22</field> |
|||
<field name="webhook_id" ref="webhook_github"/> |
|||
</record> |
|||
|
|||
|
|||
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 |
|||
|
|||
<!--webhook github data--> |
|||
<record model="webhook" id="webhook_github"> |
|||
<field name="name">github</field> |
|||
<field name="python_code_get_event">request.httprequest.headers.get('X-Github-Event')</field> |
|||
</record> |
|||
|
|||
|
|||
Full example of create a new webhook configuration data. |
|||
|
|||
.. code-block:: xml |
|||
|
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<openerp> |
|||
<data> |
|||
|
|||
<!--webhook github data--> |
|||
<record model="webhook" id="webhook_github"> |
|||
<field name="name">github</field> |
|||
<field name="python_code_get_event">request.httprequest.headers.get('X-Github-Event')</field> |
|||
</record> |
|||
<!--webhook github data of remote address--> |
|||
<record model="webhook.address" id="webhook_address_github"> |
|||
<field name="name">192.30.252.0/22</field> |
|||
<field name="webhook_id" ref="webhook_github"/> |
|||
</record> |
|||
|
|||
</data> |
|||
</openerp> |
|||
|
|||
|
|||
.. 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 |
|||
<https://github.com/OCA/server-tools/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 <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_. |
|||
|
|||
Contributors |
|||
------------ |
|||
|
|||
* Moisés López <moylop260@vauxoo.com> |
|||
|
|||
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. |
@ -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 |
@ -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, |
|||
} |
@ -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 |
@ -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/<webhook_name>'], 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) |
@ -0,0 +1,16 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<openerp> |
|||
<data> |
|||
|
|||
<!--webhook github data--> |
|||
<record model="webhook" id="webhook_github"> |
|||
<field name="name">github</field> |
|||
<field name="python_code_get_event">request.httprequest.headers.get('X-Github-Event')</field> |
|||
</record> |
|||
<record model="webhook.address" id="webhook_address_github"> |
|||
<field name="name">192.30.252.0/22</field> |
|||
<field name="webhook_id" ref="webhook_github"/> |
|||
</record> |
|||
|
|||
</data> |
|||
</openerp> |
@ -0,0 +1,17 @@ |
|||
<?xml version='1.0' encoding='UTF-8'?> |
|||
<openerp> |
|||
<data noupdate="1"> |
|||
|
|||
<record id="webhook_test" model="webhook"> |
|||
<field name="name">wehook_test</field> |
|||
<field name="python_code_get_event">request.httprequest.headers.get("X-Webhook-Test-Event")</field> |
|||
<field name="python_code_get_ip">request.httprequest.headers.get("X-Webhook-Test-Address")</field> |
|||
</record> |
|||
|
|||
<record id="webhook_address_localhost" model="webhook.address"> |
|||
<field name="name">127.0.0.1</field> |
|||
<field name="webhook_id" ref="webhook_test"/> |
|||
</record> |
|||
|
|||
</data> |
|||
</openerp> |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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") |
@ -0,0 +1,44 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<openerp> |
|||
<data> |
|||
|
|||
<record id="view_webhook_form" model="ir.ui.view"> |
|||
<field name="name">view.webhook.form</field> |
|||
<field name="model">webhook</field> |
|||
<field name="priority" eval="20"/> |
|||
<field name="arch" type="xml"> |
|||
<form> |
|||
<group> |
|||
<field name="name"/> |
|||
<field name="address_ids" widget="one2many_list"> |
|||
<tree string="Address" editable="top"> |
|||
<field name="name"/> |
|||
</tree> |
|||
</field> |
|||
<field name="python_code_get_event"/> |
|||
<field name="active"/> |
|||
</group> |
|||
</form> |
|||
</field> |
|||
</record> |
|||
<record id="view_webhook_tree" model="ir.ui.view"> |
|||
<field name="name">view.webhook.tree</field> |
|||
<field name="model">webhook</field> |
|||
<field name="priority" eval="20"/> |
|||
<field name="arch" type="xml"> |
|||
<tree> |
|||
<field name="name"/> |
|||
<field name="active"/> |
|||
</tree> |
|||
</field> |
|||
</record> |
|||
<record model="ir.actions.act_window" id="action_webhook"> |
|||
<field name="name">Webhook</field> |
|||
<field name="res_model">webhook</field> |
|||
<field name="view_type">form</field> |
|||
<field name="view_mode">tree,form</field> |
|||
</record> |
|||
<menuitem id="webhook_menu_action" name="Webhook" parent="base.menu_automation" action="action_webhook"/> |
|||
|
|||
</data> |
|||
</openerp> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue