Skip to content

Commit a03cb3d

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

17 files changed

Lines changed: 915 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: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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, eval_context)
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+
76+
try:
77+
response, body = getattr(
78+
self, "_execute_webhook_%s_request" % self.request_method
79+
)(record, eval_context)
80+
response.raise_for_status()
81+
except Exception as e:
82+
self._handle_exception(response, e, body)
83+
else:
84+
status_code = self._get_success_request_status_code(response)
85+
if status_code != 200:
86+
raise RetryableJobError
87+
self._webhook_logging(body, response)
88+
89+
def _get_webhook_headers(self):
90+
self.ensure_one()
91+
headers = json.loads(self.headers.strip()).copy() if self.headers else {}
92+
return str(headers)
93+
94+
def _prepare_data_for_post_graphql(self, template, record):
95+
def get_escaped_field(record, field_name):
96+
str_field = getattr(record, str(field_name), False)
97+
if str_field and isinstance(str_field, str):
98+
str_field = str_field.strip()
99+
for esc_char, rep_char in zip(ESCAPE_CHARS, REPLACE_CHARS):
100+
str_field = str_field.replace(esc_char, rep_char)
101+
return str_field
102+
103+
query = template.render(record=record, escape=get_escaped_field)
104+
payload = json.dumps({"query": query, "variables": {}})
105+
return payload
106+
107+
def _prepare_data_for_post_request(self, template, record, eval_context):
108+
data = template.render(**dict(eval_context, record=record))
109+
return data.encode(encoding="utf-8")
110+
111+
def _prepare_data_for_post_slack(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_get(self, template, record, eval_context):
116+
data = template.render(**dict(eval_context, record=record))
117+
return data.encode(encoding="utf-8")
118+
119+
def _execute_webhook_get_request(self, record, eval_context):
120+
self.ensure_one()
121+
122+
endpoint = self.endpoint
123+
headers = safe_eval(self._get_webhook_headers())
124+
template = Environment(loader=BaseLoader()).from_string(self.body_template)
125+
params = self._prepare_data_for_get(template, record, eval_context)
126+
r = requests.get(
127+
endpoint,
128+
params=(params or {}),
129+
headers=headers,
130+
timeout=DEFAULT_GET_TIMEOUT,
131+
)
132+
133+
return r, params
134+
135+
def _execute_webhook_post_request(self, record, eval_context):
136+
endpoint = self.endpoint
137+
headers = safe_eval(self._get_webhook_headers())
138+
template = Environment(loader=BaseLoader()).from_string(self.body_template)
139+
payload = {}
140+
141+
prepare_method = "_prepare_data_for_post_%s" % self.request_type
142+
payload = getattr(self, prepare_method)(template, record, eval_context)
143+
144+
r = requests.post(
145+
endpoint, data=payload, headers=headers, timeout=DEFAULT_POST_TIMEOUT
146+
)
147+
148+
return r, payload
149+
150+
def _get_success_request_status_code(self, response):
151+
"""
152+
Sometimes `200` success code is just weirdly return, so we explicitly check if
153+
a request is success or not based on request type.
154+
"""
155+
status_code = 200
156+
157+
if self.type == "graphql":
158+
response_data = json.loads(response.text) if response.text else False
159+
if (
160+
response_data
161+
and response_data.get("data")
162+
and isinstance(response_data.get("data"), dict)
163+
):
164+
for __, value in response_data["data"].items():
165+
if isinstance(value, dict):
166+
for k, v in value.items():
167+
if k == "statusCode":
168+
status_code = v
169+
170+
elif self.type == "slack":
171+
status_code = response.status_code
172+
173+
return status_code
174+
175+
def _webhook_logging(self, body, response):
176+
if self.log_webhook_calls:
177+
178+
with closing(registry(self.env.cr.dbname).cursor()) as cr:
179+
env = api.Environment(cr, SUPERUSER_ID, {})
180+
181+
def create_log(env, response):
182+
vals = {
183+
"webhook_type": "outgoing",
184+
"webhook": "%s (%s)" % (self.name, self),
185+
"endpoint": self.endpoint,
186+
"headers": self.headers,
187+
"request": json.dumps(ustr(body), indent=4),
188+
"response": ustr(response),
189+
"status": getattr(response, "status_code", None),
190+
}
191+
env["webhook.logging"].create(vals)
192+
env.cr.commit()
193+
194+
create_log(env, response)
195+
196+
def _handle_exception(self, response, exception, body):
197+
try:
198+
raise exception
199+
except requests.exceptions.HTTPError:
200+
_logger.error("HTTPError during request", exc_info=True)
201+
except requests.exceptions.ConnectionError:
202+
_logger.error("Error Connecting during request", exc_info=True)
203+
except requests.exceptions.Timeout:
204+
_logger.error("Connection Timeout", exc_info=True)
205+
except requests.exceptions.RequestException:
206+
_logger.error("Something wrong happened during request", exc_info=True)
207+
except Exception:
208+
# Final exception if none above catched
209+
_logger.error(
210+
"Internal exception happened during sending webhook request",
211+
exc_info=True,
212+
)
213+
finally:
214+
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)