Skip to content

Commit f9a7e3e

Browse files
committed
[ADD] webhook_incoming: trigger actions upon incoming webhook requests
1 parent 848c21f commit f9a7e3e

File tree

15 files changed

+895
-0
lines changed

15 files changed

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

setup/webhook_incoming/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+
)

test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
odoo-addon-webhook_outgoing @ git+https://github.com/OCA/webhook@refs/pull/13/head#subdirectory=setup/webhook_outgoing

webhook_incoming/README.rst

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
================
2+
Incoming 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:801eb9069d1b38681a4da1eec8219ce59055f9cea46af867d49a7be05b955dc4
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_incoming
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_incoming
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_incoming%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 <[email protected]>
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_incoming>`_ project on GitHub.
82+
83+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

webhook_incoming/__init__.py

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

webhook_incoming/__manifest__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright 2024 Hoang Tran <[email protected]>.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
4+
{
5+
"name": "Incoming Webhook",
6+
"summary": "Receive incoming webhook requests as trigger to execute tasks.",
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": ["base_automation", "webhook_outgoing", "queue_job"],
12+
"data": [
13+
"views/base_automation_views.xml",
14+
],
15+
"auto_install": True,
16+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import main

webhook_incoming/controllers/main.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright 2024 Hoang Tran <[email protected]>.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
from odoo.http import Controller, request, route
4+
5+
6+
def get_webhook_request_payload():
7+
if not request:
8+
return None
9+
try:
10+
payload = request.get_json_data()
11+
except ValueError:
12+
payload = {**request.httprequest.args}
13+
return payload
14+
15+
16+
class BaseAutomationController(Controller):
17+
@route(
18+
["/web/hook/<string:rule_uuid>"],
19+
type="http",
20+
auth="public",
21+
methods=["GET", "POST"],
22+
csrf=False,
23+
save_session=False,
24+
)
25+
def call_webhook_http(self, rule_uuid, **kwargs):
26+
"""Execute an automation webhook"""
27+
rule = (
28+
request.env["base.automation"]
29+
.sudo()
30+
.search([("webhook_uuid", "=", rule_uuid)])
31+
)
32+
if not rule:
33+
return request.make_json_response({"status": "error"}, status=404)
34+
35+
data = get_webhook_request_payload()
36+
try:
37+
rule._execute_webhook(data)
38+
except Exception: # noqa: BLE001
39+
return request.make_json_response({"status": "error"}, status=500)
40+
return request.make_json_response({"status": "ok"}, status=200)

webhook_incoming/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import base_automation
2+
from . import ir_actions_server
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# Copyright 2024 Hoang Tran <[email protected]>.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
3+
import base64
4+
import logging
5+
import traceback
6+
from uuid import uuid4
7+
8+
from pytz import timezone
9+
10+
from odoo import Command, _, api, exceptions, fields, models, tools
11+
from odoo.tools import ustr
12+
from odoo.tools.float_utils import float_compare
13+
from odoo.tools.safe_eval import safe_eval
14+
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
class InheritedBaseAutomation(models.Model):
19+
_inherit = "base.automation"
20+
21+
trigger = fields.Selection(
22+
selection_add=[("on_webhook", "On webhook")], ondelete={"on_webhook": "cascade"}
23+
)
24+
webhook_uuid = fields.Char(
25+
string="Webhook UUID",
26+
readonly=True,
27+
copy=False,
28+
default=lambda self: str(uuid4()),
29+
)
30+
url = fields.Char(string="Webhook URL", compute="_compute_url")
31+
log_webhook_calls = fields.Boolean(string="Log Calls", default=False)
32+
allow_creation = fields.Boolean(
33+
string="Allow creation?",
34+
help="Allow executing webhook to maybe create record if a record is not "
35+
"found using record getter",
36+
)
37+
record_getter = fields.Char(
38+
default="model.env[payload.get('_model')].browse(int(payload.get('_id')))",
39+
help="This code will be run to find on which record the automation rule should be run.",
40+
)
41+
create_record_code = fields.Text(
42+
"Record Creation Code",
43+
default="""# Available variables:
44+
# - env: Odoo Environment on which the action is triggered
45+
# - model: Odoo Model of the record on which the action is triggered;
46+
# is a void recordset
47+
# - record: record on which the action is triggered; may be void
48+
# - records: recordset of all records on which the action is triggered
49+
# in multi-mode; may be void
50+
# - payload: input payload from webhook request
51+
# - time, datetime, dateutil, timezone: useful Python libraries
52+
# - float_compare: Odoo function to compare floats based on specific precisions
53+
# - log: log(message, level='info'): logging function to record debug information
54+
# in ir.logging table
55+
# - UserError: Warning Exception to use with raise
56+
# - Command: x2Many commands namespace
57+
# You must return the created record by assign it to `record` variable:
58+
# - record = res.partner(1,)
59+
""",
60+
help="This block of code is eval if Record Getter couldn't find a matching record.",
61+
)
62+
create_record_action_id = fields.Many2one(comodel_name="ir.actions.server")
63+
delay_execution = fields.Boolean(
64+
help="Queue actions to perform to delay execution."
65+
)
66+
67+
@api.depends("webhook_uuid")
68+
def _compute_webhook_url(self):
69+
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
70+
for webhook in self:
71+
webhook.webhook_url = "%s/web/hook/%s" % (base_url, webhook.webhook_uuid)
72+
73+
@api.depends("trigger", "webhook_uuid")
74+
def _compute_url(self):
75+
for automation in self:
76+
if automation.trigger != "on_webhook":
77+
automation.url = ""
78+
else:
79+
automation.url = "%s/web/hook/%s" % (
80+
automation.get_base_url(),
81+
automation.webhook_uuid,
82+
)
83+
84+
def _get_eval_context(self, payload=None):
85+
"""
86+
Override to add payload to context
87+
"""
88+
eval_context = super()._get_eval_context()
89+
eval_context["model"] = self.env[self.model_name]
90+
eval_context["payload"] = payload if payload is not None else {}
91+
return eval_context
92+
93+
def _execute_webhook(self, payload):
94+
"""Execute the webhook for the given payload.
95+
The payload is a dictionnary that can be used by the `record_getter` to
96+
identify the record on which the automation should be run.
97+
"""
98+
self.ensure_one()
99+
100+
# info logging is done by the ir.http logger
101+
msg = "Webhook #%s triggered with payload %s"
102+
msg_args = (self.id, payload)
103+
_logger.debug(msg, *msg_args)
104+
105+
record = self.env[self.model_name]
106+
eval_context = self._get_eval_context(payload=payload)
107+
108+
if self.record_getter:
109+
try:
110+
record = safe_eval(self.record_getter, eval_context)
111+
except Exception as e: # noqa: BLE001
112+
msg = "Webhook #%s could not be triggered because the record_getter failed:\n%s"
113+
msg_args = (self.id, traceback.format_exc())
114+
_logger.warning(msg, *msg_args)
115+
self._webhook_logging(payload, self._add_postmortem(e))
116+
raise e
117+
118+
if not record.exists() and self.allow_creation:
119+
try:
120+
create_eval_context = self._get_create_eval_context(payload=payload)
121+
safe_eval(
122+
self.create_record_code,
123+
create_eval_context,
124+
mode="exec",
125+
nocopy=True,
126+
) # nocopy allows to return 'action'
127+
record = create_eval_context.get("record", self.model_id.browse())
128+
except Exception as e: # noqa: BLE001
129+
msg = "Webhook #%s failed with error:\n%s"
130+
msg_args = (self.id, traceback.format_exc())
131+
_logger.warning(msg, *msg_args)
132+
self._webhook_logging(payload, self._add_postmortem(e))
133+
elif not record.exists():
134+
msg = "Webhook #%s could not be triggered because no record to run it on was found."
135+
msg_args = (self.id,)
136+
_logger.warning(msg, *msg_args)
137+
self._webhook_logging(payload, msg)
138+
raise exceptions.ValidationError(
139+
_("No record to run the automation on was found.")
140+
)
141+
142+
try:
143+
# quirk: base.automation(,)._process has a ``context["__action_done"]``
144+
# at the very beginning of the function while it wasn't set before-hand.
145+
# so setting this context now to avoid further issue advancing forward.
146+
if "__action_done" not in self._context:
147+
self = self.with_context(__action_done={}, payload=payload)
148+
return self._process(record)
149+
except Exception as e: # noqa: BLE001
150+
msg = "Webhook #%s failed with error:\n%s"
151+
msg_args = (self.id, traceback.format_exc())
152+
_logger.warning(msg, *msg_args)
153+
self._webhook_logging(payload, self._add_postmortem(e))
154+
raise e
155+
finally:
156+
self._webhook_logging(payload, None)
157+
158+
def _get_create_eval_context(self, payload=None):
159+
def log(message, level="info"):
160+
with self.pool.cursor() as cr:
161+
cr.execute(
162+
"""
163+
INSERT INTO ir_logging(
164+
create_date, create_uid, type, dbname, name,
165+
level, message, path, line, func
166+
)
167+
VALUES (NOW() at time zone 'UTC', %s, %s, %s, %s, %s, %s, %s, %s, %s)
168+
""",
169+
(
170+
self.env.uid,
171+
"server",
172+
self._cr.dbname,
173+
__name__,
174+
level,
175+
message,
176+
"action",
177+
self.id,
178+
self.name,
179+
),
180+
)
181+
182+
eval_context = dict(self.env.context)
183+
model_name = self.model_id.sudo().model
184+
model = self.env[model_name]
185+
eval_context.update(
186+
{
187+
"uid": self._uid,
188+
"user": self.env.user,
189+
"time": tools.safe_eval.time,
190+
"datetime": tools.safe_eval.datetime,
191+
"dateutil": tools.safe_eval.dateutil,
192+
"timezone": timezone,
193+
"float_compare": float_compare,
194+
"b64encode": base64.b64encode,
195+
"b64decode": base64.b64decode,
196+
"Command": Command,
197+
"env": self.env,
198+
"model": model,
199+
"log": log,
200+
"payload": payload,
201+
}
202+
)
203+
return eval_context
204+
205+
def _webhook_logging(self, body, response):
206+
if self.log_webhook_calls:
207+
208+
vals = {
209+
"webhook_type": "incoming",
210+
"webhook": "%s (%s)" % (self.name, self),
211+
"endpoint": self.url,
212+
"headers": "{}",
213+
"request": ustr(body),
214+
"body": "{}",
215+
"response": ustr(response),
216+
"status": getattr(response, "status_code", None),
217+
}
218+
self.env["webhook.logging"].create(vals)
219+
220+
def _process(self, records, domain_post=None):
221+
"""
222+
Override to allow delay execution
223+
"""
224+
to_delay = self.filtered(lambda a: a.delay_execution)
225+
execute_now = self - to_delay
226+
227+
super(
228+
InheritedBaseAutomation,
229+
to_delay.with_context(delay_execution=True),
230+
)._process(records, domain_post=domain_post)
231+
232+
return super(InheritedBaseAutomation, execute_now)._process(
233+
records, domain_post=domain_post
234+
)

0 commit comments

Comments
 (0)