Skip to content

Commit

Permalink
[ADD] webhook_outgoing: send outgoing webhook requests
Browse files Browse the repository at this point in the history
  • Loading branch information
hoangtrann committed Sep 17, 2024
1 parent 848c21f commit 59c9c13
Show file tree
Hide file tree
Showing 17 changed files with 919 additions and 0 deletions.
1 change: 1 addition & 0 deletions setup/webhook_outgoing/odoo/addons/webhook_outgoing
6 changes: 6 additions & 0 deletions setup/webhook_outgoing/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
83 changes: 83 additions & 0 deletions webhook_outgoing/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
================
Outgoing Webhook
================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:8bdaa6bf8f8de957410bd7bde59aa2ef3a84eaecce4da8d7d349b040e297dd77
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwebhook-lightgray.png?logo=github
:target: https://github.com/OCA/webhook/tree/16.0/webhook_outgoing
:alt: OCA/webhook
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/webhook-16-0/webhook-16-0-webhook_outgoing
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/webhook&target_branch=16.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module allow creating an automation that send webhook/requests to another systems via HTTP.

To create a new automation to send webhook requests, go to Settings > Automated Actions:

* When add an automation, choose `Custom Webhook` as action to perform.
* Config Endpoint, Headers and Body Template accordingly.

This webhook action use Jinja and rendering engine, you can draft body template using Jinja syntax.

**Table of contents**

.. contents::
:local:

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/webhook/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`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**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
~~~~~~~

* Hoang Tran

Contributors
~~~~~~~~~~~~

* Hoang Tran <[email protected]>

Maintainers
~~~~~~~~~~~

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

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.

This module is part of the `OCA/webhook <https://github.com/OCA/webhook/tree/16.0/webhook_outgoing>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions webhook_outgoing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
23 changes: 23 additions & 0 deletions webhook_outgoing/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright 2024 Hoang Tran <[email protected]>.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).

{
"name": "Outgoing Webhook",
"summary": "Webhook to publish events based on automated triggers",
"version": "16.0.0.0.1",
"author": "Hoang Tran,Odoo Community Association (OCA)",
"license": "LGPL-3",
"website": "https://github.com/OCA/webhook",
"depends": [
"base_automation",
"queue_job",
],
"data": [
"security/ir.model.access.csv",
"data/queue_data.xml",
"views/webhook_logging_views.xml",
"views/ir_action_server_views.xml",
"views/menus.xml",
],
"installable": True,
}
7 changes: 7 additions & 0 deletions webhook_outgoing/data/queue_data.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8' ?>
<odoo>
<record id="webhook_channel" model="queue.job.channel">
<field name="name">webhook</field>
<field name="parent_id" ref="queue_job.channel_root" />
</record>
</odoo>
2 changes: 2 additions & 0 deletions webhook_outgoing/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import ir_action_server
from . import webhook_logging
218 changes: 218 additions & 0 deletions webhook_outgoing/models/ir_action_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# Copyright 2024 Hoang Tran <[email protected]>.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import json
import logging
from contextlib import closing

import requests
from jinja2 import BaseLoader, Environment

from odoo import SUPERUSER_ID, api, fields, models, registry
from odoo.tools import ustr
from odoo.tools.safe_eval import safe_eval

from odoo.addons.queue_job.exception import RetryableJobError

_logger = logging.getLevelName(__name__)


ESCAPE_CHARS = ['"', "\n", "\r", "\t", "\b", "\f"]
REPLACE_CHARS = ['\\"', "\\n", "\\r", "\\t", "\\b", "\\f"]

DEFAULT_GET_TIMEOUT = 5
DEFAULT_POST_TIMEOUT = 5


class ServerAction(models.Model):
_inherit = "ir.actions.server"

state = fields.Selection(
selection_add=[("custom_webhook", "Custom Webhook")],
ondelete={"custom_webhook": "cascade"},
)
endpoint = fields.Char()
headers = fields.Text(default="{}")
body_template = fields.Text(default="{}")
request_method = fields.Selection(
[
("get", "GET"),
("post", "POST"),
],
default="post",
)
request_type = fields.Selection(
[
("request", "HTTP Request"),
("graphql", "GraphQL"),
("slack", "Slack"),
],
default="request",
)
log_webhook_calls = fields.Boolean(string="Log Calls", default=False)
delay_execution = fields.Boolean()
delay = fields.Integer("Delay ETA (s)", default=0)

def _run_action_custom_webhook_multi(self, eval_context):
"""
Execute to send webhook requests to triggered records. Note that execution
is done on each record and not in batch.
"""
records = eval_context.get("records", self.model_id.browse())

Check warning on line 60 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L60

Added line #L60 was not covered by tests

for record in records:
if self.delay_execution:
self.with_delay(eta=self.delay)._execute_webhook(record, None)

Check warning on line 64 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L64

Added line #L64 was not covered by tests
else:
self._execute_webhook(record, eval_context)

Check warning on line 66 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L66

Added line #L66 was not covered by tests

return eval_context.get("action")

Check warning on line 68 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L68

Added line #L68 was not covered by tests

def _execute_webhook(self, record, eval_context):
"""
Prepare request params/body by rendering template and send requests.
"""
self.ensure_one()

Check warning on line 74 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L74

Added line #L74 was not covered by tests
if eval_context is None:
eval_context = dict(

Check warning on line 76 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L76

Added line #L76 was not covered by tests
self._get_eval_context(action=self), record=record, records=record
)

try:
response, body = getattr(

Check warning on line 81 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L80-L81

Added lines #L80 - L81 were not covered by tests
self, "_execute_webhook_%s_request" % self.request_method
)(record, eval_context)
response.raise_for_status()
except Exception as e:
self._handle_exception(response, e, body)

Check warning on line 86 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L84-L86

Added lines #L84 - L86 were not covered by tests
else:
status_code = self._get_success_request_status_code(response)

Check warning on line 88 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L88

Added line #L88 was not covered by tests
if status_code != 200:
raise RetryableJobError
self._webhook_logging(body, response)

Check warning on line 91 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L90-L91

Added lines #L90 - L91 were not covered by tests

def _get_webhook_headers(self):
self.ensure_one()
headers = json.loads(self.headers.strip()).copy() if self.headers else {}
return str(headers)

Check warning on line 96 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L94-L96

Added lines #L94 - L96 were not covered by tests

def _prepare_data_for_post_graphql(self, template, record):
def get_escaped_field(record, field_name):
str_field = getattr(record, str(field_name), False)

Check warning on line 100 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L99-L100

Added lines #L99 - L100 were not covered by tests
if str_field and isinstance(str_field, str):
str_field = str_field.strip()

Check warning on line 102 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L102

Added line #L102 was not covered by tests
for esc_char, rep_char in zip(ESCAPE_CHARS, REPLACE_CHARS):
str_field = str_field.replace(esc_char, rep_char)
return str_field

Check warning on line 105 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L104-L105

Added lines #L104 - L105 were not covered by tests

query = template.render(record=record, escape=get_escaped_field)
payload = json.dumps({"query": query, "variables": {}})
return payload

Check warning on line 109 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L107-L109

Added lines #L107 - L109 were not covered by tests

def _prepare_data_for_post_request(self, template, record, eval_context):
data = template.render(**dict(eval_context, record=record))
return data.encode(encoding="utf-8")

Check warning on line 113 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L112-L113

Added lines #L112 - L113 were not covered by tests

def _prepare_data_for_post_slack(self, template, record, eval_context):
data = template.render(**dict(eval_context, record=record))
return data.encode(encoding="utf-8")

Check warning on line 117 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L116-L117

Added lines #L116 - L117 were not covered by tests

def _prepare_data_for_get(self, template, record, eval_context):
data = template.render(**dict(eval_context, record=record))
return data.encode(encoding="utf-8")

Check warning on line 121 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L120-L121

Added lines #L120 - L121 were not covered by tests

def _execute_webhook_get_request(self, record, eval_context):
self.ensure_one()

Check warning on line 124 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L124

Added line #L124 was not covered by tests

endpoint = self.endpoint
headers = safe_eval(self._get_webhook_headers())
template = Environment(loader=BaseLoader()).from_string(self.body_template)
params = self._prepare_data_for_get(template, record, eval_context)
r = requests.get(

Check warning on line 130 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L126-L130

Added lines #L126 - L130 were not covered by tests
endpoint,
params=(params or {}),
headers=headers,
timeout=DEFAULT_GET_TIMEOUT,
)

return r, params

Check warning on line 137 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L137

Added line #L137 was not covered by tests

def _execute_webhook_post_request(self, record, eval_context):
endpoint = self.endpoint
headers = safe_eval(self._get_webhook_headers())
template = Environment(loader=BaseLoader()).from_string(self.body_template)
payload = {}

Check warning on line 143 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L140-L143

Added lines #L140 - L143 were not covered by tests

prepare_method = "_prepare_data_for_post_%s" % self.request_type
payload = getattr(self, prepare_method)(template, record, eval_context)

Check warning on line 146 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L145-L146

Added lines #L145 - L146 were not covered by tests

r = requests.post(

Check warning on line 148 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L148

Added line #L148 was not covered by tests
endpoint, data=payload, headers=headers, timeout=DEFAULT_POST_TIMEOUT
)

return r, payload

Check warning on line 152 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L152

Added line #L152 was not covered by tests

def _get_success_request_status_code(self, response):
"""
Sometimes `200` success code is just weirdly return, so we explicitly check if
a request is success or not based on request type.
"""
status_code = 200

Check warning on line 159 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L159

Added line #L159 was not covered by tests

if self.type == "graphql":
response_data = json.loads(response.text) if response.text else False

Check warning on line 162 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L162

Added line #L162 was not covered by tests
if (
response_data
and response_data.get("data")
and isinstance(response_data.get("data"), dict)
):
for __, value in response_data["data"].items():
if isinstance(value, dict):
for k, v in value.items():
if k == "statusCode":
status_code = v

Check warning on line 172 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L172

Added line #L172 was not covered by tests

elif self.type == "slack":
status_code = response.status_code

Check warning on line 175 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L175

Added line #L175 was not covered by tests

return status_code

Check warning on line 177 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L177

Added line #L177 was not covered by tests

def _webhook_logging(self, body, response):
if self.log_webhook_calls:

with closing(registry(self.env.cr.dbname).cursor()) as cr:
env = api.Environment(cr, SUPERUSER_ID, {})

Check warning on line 183 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L183

Added line #L183 was not covered by tests

def create_log(env, response):
vals = {

Check warning on line 186 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L185-L186

Added lines #L185 - L186 were not covered by tests
"webhook_type": "outgoing",
"webhook": "%s (%s)" % (self.name, self),
"endpoint": self.endpoint,
"headers": self.headers,
"request": json.dumps(ustr(body), indent=4),
"response": ustr(response),
"status": getattr(response, "status_code", None),
}
env["webhook.logging"].create(vals)
env.cr.commit()

Check warning on line 196 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L195-L196

Added lines #L195 - L196 were not covered by tests

create_log(env, response)

Check warning on line 198 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L198

Added line #L198 was not covered by tests

def _handle_exception(self, response, exception, body):
try:
raise exception

Check warning on line 202 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L201-L202

Added lines #L201 - L202 were not covered by tests
except requests.exceptions.HTTPError:
_logger.error("HTTPError during request", exc_info=True)

Check warning on line 204 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L204

Added line #L204 was not covered by tests
except requests.exceptions.ConnectionError:
_logger.error("Error Connecting during request", exc_info=True)

Check warning on line 206 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L206

Added line #L206 was not covered by tests
except requests.exceptions.Timeout:
_logger.error("Connection Timeout", exc_info=True)

Check warning on line 208 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L208

Added line #L208 was not covered by tests
except requests.exceptions.RequestException:
_logger.error("Something wrong happened during request", exc_info=True)
except Exception:

Check warning on line 211 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L210-L211

Added lines #L210 - L211 were not covered by tests
# Final exception if none above catched
_logger.error(

Check warning on line 213 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L213

Added line #L213 was not covered by tests
"Internal exception happened during sending webhook request",
exc_info=True,
)
finally:
self._webhook_logging(body, exception)

Check warning on line 218 in webhook_outgoing/models/ir_action_server.py

View check run for this annotation

Codecov / codecov/patch

webhook_outgoing/models/ir_action_server.py#L218

Added line #L218 was not covered by tests
28 changes: 28 additions & 0 deletions webhook_outgoing/models/webhook_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright 2024 Hoang Tran <[email protected]>.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).

import uuid

from odoo import fields, models


class WebhookLog(models.Model):
_name = "webhook.logging"
_description = "Webhook Logging"
_order = "id DESC"

name = fields.Char(string="Reference", default=lambda self: str(uuid.uuid4()))
webhook_type = fields.Selection(
selection=[
("incoming", "Incoming"),
("outgoing", "Outgoing"),
],
string="Type",
)
webhook = fields.Char()
endpoint = fields.Char()
headers = fields.Char()
status = fields.Char()
body = fields.Text()
request = fields.Text()
response = fields.Text()
1 change: 1 addition & 0 deletions webhook_outgoing/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Hoang Tran <[email protected]>
8 changes: 8 additions & 0 deletions webhook_outgoing/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
This module allow creating an automation that send webhook/requests to another systems via HTTP.

To create a new automation to send webhook requests, go to Settings > Automated Actions:

* When add an automation, choose `Custom Webhook` as action to perform.
* Config Endpoint, Headers and Body Template accordingly.

This webhook action use Jinja and rendering engine, you can draft body template using Jinja syntax.
2 changes: 2 additions & 0 deletions webhook_outgoing/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_webhook_logging_base_user,access.webhook.logging.base.user,model_webhook_logging,base.group_system,1,1,1,1
Binary file added webhook_outgoing/static/description/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 59c9c13

Please sign in to comment.