Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] webhook_outgoing: send outgoing webhooks #13

Open
wants to merge 1 commit into
base: 16.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading