Skip to content

Commit 59c9c13

Browse files
committed
[ADD] webhook_outgoing: send outgoing webhook requests
1 parent 848c21f commit 59c9c13

17 files changed

Lines changed: 919 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../../webhook_outgoing

setup/webhook_outgoing/setup.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import setuptools
2+
3+
setuptools.setup(
4+
setup_requires=['setuptools-odoo'],
5+
odoo_addon=True,
6+
)

webhook_outgoing/README.rst

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
================
2+
Outgoing Webhook
3+
================
4+
5+
..
6+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
7+
!! This file is generated by oca-gen-addon-readme !!
8+
!! changes will be overwritten. !!
9+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10+
!! source digest: sha256:8bdaa6bf8f8de957410bd7bde59aa2ef3a84eaecce4da8d7d349b040e297dd77
11+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
12+
13+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
14+
:target: https://odoo-community.org/page/development-status
15+
:alt: Beta
16+
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
17+
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
18+
:alt: License: LGPL-3
19+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwebhook-lightgray.png?logo=github
20+
:target: https://github.com/OCA/webhook/tree/16.0/webhook_outgoing
21+
:alt: OCA/webhook
22+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
23+
:target: https://translation.odoo-community.org/projects/webhook-16-0/webhook-16-0-webhook_outgoing
24+
:alt: Translate me on Weblate
25+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
26+
:target: https://runboat.odoo-community.org/builds?repo=OCA/webhook&target_branch=16.0
27+
:alt: Try me on Runboat
28+
29+
|badge1| |badge2| |badge3| |badge4| |badge5|
30+
31+
This module allow creating an automation that send webhook/requests to another systems via HTTP.
32+
33+
To create a new automation to send webhook requests, go to Settings > Automated Actions:
34+
35+
* When add an automation, choose `Custom Webhook` as action to perform.
36+
* Config Endpoint, Headers and Body Template accordingly.
37+
38+
This webhook action use Jinja and rendering engine, you can draft body template using Jinja syntax.
39+
40+
**Table of contents**
41+
42+
.. contents::
43+
:local:
44+
45+
Bug Tracker
46+
===========
47+
48+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/webhook/issues>`_.
49+
In case of trouble, please check there if your issue has already been reported.
50+
If you spotted it first, help us to smash it by providing a detailed and welcomed
51+
`feedback <https://github.com/OCA/webhook/issues/new?body=module:%20webhook_outgoing%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
52+
53+
Do not contact contributors directly about support or help with technical issues.
54+
55+
Credits
56+
=======
57+
58+
Authors
59+
~~~~~~~
60+
61+
* Hoang Tran
62+
63+
Contributors
64+
~~~~~~~~~~~~
65+
66+
* Hoang Tran <thhoang.tr@gmail.com>
67+
68+
Maintainers
69+
~~~~~~~~~~~
70+
71+
This module is maintained by the OCA.
72+
73+
.. image:: https://odoo-community.org/logo.png
74+
:alt: Odoo Community Association
75+
:target: https://odoo-community.org
76+
77+
OCA, or the Odoo Community Association, is a nonprofit organization whose
78+
mission is to support the collaborative development of Odoo features and
79+
promote its widespread use.
80+
81+
This module is part of the `OCA/webhook <https://github.com/OCA/webhook/tree/16.0/webhook_outgoing>`_ project on GitHub.
82+
83+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

webhook_outgoing/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models

webhook_outgoing/__manifest__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2024 Hoang Tran <thhoang.tr@gmail.com>.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
4+
{
5+
"name": "Outgoing Webhook",
6+
"summary": "Webhook to publish events based on automated triggers",
7+
"version": "16.0.0.0.1",
8+
"author": "Hoang Tran,Odoo Community Association (OCA)",
9+
"license": "LGPL-3",
10+
"website": "https://github.com/OCA/webhook",
11+
"depends": [
12+
"base_automation",
13+
"queue_job",
14+
],
15+
"data": [
16+
"security/ir.model.access.csv",
17+
"data/queue_data.xml",
18+
"views/webhook_logging_views.xml",
19+
"views/ir_action_server_views.xml",
20+
"views/menus.xml",
21+
],
22+
"installable": True,
23+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version='1.0' encoding='utf-8' ?>
2+
<odoo>
3+
<record id="webhook_channel" model="queue.job.channel">
4+
<field name="name">webhook</field>
5+
<field name="parent_id" ref="queue_job.channel_root" />
6+
</record>
7+
</odoo>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import ir_action_server
2+
from . import webhook_logging
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# Copyright 2024 Hoang Tran <thhoang.tr@gmail.com>.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
import json
4+
import logging
5+
from contextlib import closing
6+
7+
import requests
8+
from jinja2 import BaseLoader, Environment
9+
10+
from odoo import SUPERUSER_ID, api, fields, models, registry
11+
from odoo.tools import ustr
12+
from odoo.tools.safe_eval import safe_eval
13+
14+
from odoo.addons.queue_job.exception import RetryableJobError
15+
16+
_logger = logging.getLevelName(__name__)
17+
18+
19+
ESCAPE_CHARS = ['"', "\n", "\r", "\t", "\b", "\f"]
20+
REPLACE_CHARS = ['\\"', "\\n", "\\r", "\\t", "\\b", "\\f"]
21+
22+
DEFAULT_GET_TIMEOUT = 5
23+
DEFAULT_POST_TIMEOUT = 5
24+
25+
26+
class ServerAction(models.Model):
27+
_inherit = "ir.actions.server"
28+
29+
state = fields.Selection(
30+
selection_add=[("custom_webhook", "Custom Webhook")],
31+
ondelete={"custom_webhook": "cascade"},
32+
)
33+
endpoint = fields.Char()
34+
headers = fields.Text(default="{}")
35+
body_template = fields.Text(default="{}")
36+
request_method = fields.Selection(
37+
[
38+
("get", "GET"),
39+
("post", "POST"),
40+
],
41+
default="post",
42+
)
43+
request_type = fields.Selection(
44+
[
45+
("request", "HTTP Request"),
46+
("graphql", "GraphQL"),
47+
("slack", "Slack"),
48+
],
49+
default="request",
50+
)
51+
log_webhook_calls = fields.Boolean(string="Log Calls", default=False)
52+
delay_execution = fields.Boolean()
53+
delay = fields.Integer("Delay ETA (s)", default=0)
54+
55+
def _run_action_custom_webhook_multi(self, eval_context):
56+
"""
57+
Execute to send webhook requests to triggered records. Note that execution
58+
is done on each record and not in batch.
59+
"""
60+
records = eval_context.get("records", self.model_id.browse())
61+
62+
for record in records:
63+
if self.delay_execution:
64+
self.with_delay(eta=self.delay)._execute_webhook(record, None)
65+
else:
66+
self._execute_webhook(record, eval_context)
67+
68+
return eval_context.get("action")
69+
70+
def _execute_webhook(self, record, eval_context):
71+
"""
72+
Prepare request params/body by rendering template and send requests.
73+
"""
74+
self.ensure_one()
75+
if eval_context is None:
76+
eval_context = dict(
77+
self._get_eval_context(action=self), record=record, records=record
78+
)
79+
80+
try:
81+
response, body = getattr(
82+
self, "_execute_webhook_%s_request" % self.request_method
83+
)(record, eval_context)
84+
response.raise_for_status()
85+
except Exception as e:
86+
self._handle_exception(response, e, body)
87+
else:
88+
status_code = self._get_success_request_status_code(response)
89+
if status_code != 200:
90+
raise RetryableJobError
91+
self._webhook_logging(body, response)
92+
93+
def _get_webhook_headers(self):
94+
self.ensure_one()
95+
headers = json.loads(self.headers.strip()).copy() if self.headers else {}
96+
return str(headers)
97+
98+
def _prepare_data_for_post_graphql(self, template, record):
99+
def get_escaped_field(record, field_name):
100+
str_field = getattr(record, str(field_name), False)
101+
if str_field and isinstance(str_field, str):
102+
str_field = str_field.strip()
103+
for esc_char, rep_char in zip(ESCAPE_CHARS, REPLACE_CHARS):
104+
str_field = str_field.replace(esc_char, rep_char)
105+
return str_field
106+
107+
query = template.render(record=record, escape=get_escaped_field)
108+
payload = json.dumps({"query": query, "variables": {}})
109+
return payload
110+
111+
def _prepare_data_for_post_request(self, template, record, eval_context):
112+
data = template.render(**dict(eval_context, record=record))
113+
return data.encode(encoding="utf-8")
114+
115+
def _prepare_data_for_post_slack(self, template, record, eval_context):
116+
data = template.render(**dict(eval_context, record=record))
117+
return data.encode(encoding="utf-8")
118+
119+
def _prepare_data_for_get(self, template, record, eval_context):
120+
data = template.render(**dict(eval_context, record=record))
121+
return data.encode(encoding="utf-8")
122+
123+
def _execute_webhook_get_request(self, record, eval_context):
124+
self.ensure_one()
125+
126+
endpoint = self.endpoint
127+
headers = safe_eval(self._get_webhook_headers())
128+
template = Environment(loader=BaseLoader()).from_string(self.body_template)
129+
params = self._prepare_data_for_get(template, record, eval_context)
130+
r = requests.get(
131+
endpoint,
132+
params=(params or {}),
133+
headers=headers,
134+
timeout=DEFAULT_GET_TIMEOUT,
135+
)
136+
137+
return r, params
138+
139+
def _execute_webhook_post_request(self, record, eval_context):
140+
endpoint = self.endpoint
141+
headers = safe_eval(self._get_webhook_headers())
142+
template = Environment(loader=BaseLoader()).from_string(self.body_template)
143+
payload = {}
144+
145+
prepare_method = "_prepare_data_for_post_%s" % self.request_type
146+
payload = getattr(self, prepare_method)(template, record, eval_context)
147+
148+
r = requests.post(
149+
endpoint, data=payload, headers=headers, timeout=DEFAULT_POST_TIMEOUT
150+
)
151+
152+
return r, payload
153+
154+
def _get_success_request_status_code(self, response):
155+
"""
156+
Sometimes `200` success code is just weirdly return, so we explicitly check if
157+
a request is success or not based on request type.
158+
"""
159+
status_code = 200
160+
161+
if self.type == "graphql":
162+
response_data = json.loads(response.text) if response.text else False
163+
if (
164+
response_data
165+
and response_data.get("data")
166+
and isinstance(response_data.get("data"), dict)
167+
):
168+
for __, value in response_data["data"].items():
169+
if isinstance(value, dict):
170+
for k, v in value.items():
171+
if k == "statusCode":
172+
status_code = v
173+
174+
elif self.type == "slack":
175+
status_code = response.status_code
176+
177+
return status_code
178+
179+
def _webhook_logging(self, body, response):
180+
if self.log_webhook_calls:
181+
182+
with closing(registry(self.env.cr.dbname).cursor()) as cr:
183+
env = api.Environment(cr, SUPERUSER_ID, {})
184+
185+
def create_log(env, response):
186+
vals = {
187+
"webhook_type": "outgoing",
188+
"webhook": "%s (%s)" % (self.name, self),
189+
"endpoint": self.endpoint,
190+
"headers": self.headers,
191+
"request": json.dumps(ustr(body), indent=4),
192+
"response": ustr(response),
193+
"status": getattr(response, "status_code", None),
194+
}
195+
env["webhook.logging"].create(vals)
196+
env.cr.commit()
197+
198+
create_log(env, response)
199+
200+
def _handle_exception(self, response, exception, body):
201+
try:
202+
raise exception
203+
except requests.exceptions.HTTPError:
204+
_logger.error("HTTPError during request", exc_info=True)
205+
except requests.exceptions.ConnectionError:
206+
_logger.error("Error Connecting during request", exc_info=True)
207+
except requests.exceptions.Timeout:
208+
_logger.error("Connection Timeout", exc_info=True)
209+
except requests.exceptions.RequestException:
210+
_logger.error("Something wrong happened during request", exc_info=True)
211+
except Exception:
212+
# Final exception if none above catched
213+
_logger.error(
214+
"Internal exception happened during sending webhook request",
215+
exc_info=True,
216+
)
217+
finally:
218+
self._webhook_logging(body, exception)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright 2024 Hoang Tran <thhoang.tr@gmail.com>.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
4+
import uuid
5+
6+
from odoo import fields, models
7+
8+
9+
class WebhookLog(models.Model):
10+
_name = "webhook.logging"
11+
_description = "Webhook Logging"
12+
_order = "id DESC"
13+
14+
name = fields.Char(string="Reference", default=lambda self: str(uuid.uuid4()))
15+
webhook_type = fields.Selection(
16+
selection=[
17+
("incoming", "Incoming"),
18+
("outgoing", "Outgoing"),
19+
],
20+
string="Type",
21+
)
22+
webhook = fields.Char()
23+
endpoint = fields.Char()
24+
headers = fields.Char()
25+
status = fields.Char()
26+
body = fields.Text()
27+
request = fields.Text()
28+
response = fields.Text()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Hoang Tran <thhoang.tr@gmail.com>

0 commit comments

Comments
 (0)