diff --git a/setup/webhook_outgoing/odoo/addons/webhook_outgoing b/setup/webhook_outgoing/odoo/addons/webhook_outgoing new file mode 120000 index 0000000..0ecf4dc --- /dev/null +++ b/setup/webhook_outgoing/odoo/addons/webhook_outgoing @@ -0,0 +1 @@ +../../../../webhook_outgoing \ No newline at end of file diff --git a/setup/webhook_outgoing/setup.py b/setup/webhook_outgoing/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/webhook_outgoing/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/webhook_outgoing/README.rst b/webhook_outgoing/README.rst new file mode 100644 index 0000000..37ac624 --- /dev/null +++ b/webhook_outgoing/README.rst @@ -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 `_. +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Hoang Tran + +Contributors +~~~~~~~~~~~~ + +* Hoang Tran + +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 `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/webhook_outgoing/__init__.py b/webhook_outgoing/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/webhook_outgoing/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/webhook_outgoing/__manifest__.py b/webhook_outgoing/__manifest__.py new file mode 100644 index 0000000..9448815 --- /dev/null +++ b/webhook_outgoing/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2024 Hoang Tran . +# 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, +} diff --git a/webhook_outgoing/data/queue_data.xml b/webhook_outgoing/data/queue_data.xml new file mode 100644 index 0000000..331b524 --- /dev/null +++ b/webhook_outgoing/data/queue_data.xml @@ -0,0 +1,7 @@ + + + + webhook + + + diff --git a/webhook_outgoing/models/__init__.py b/webhook_outgoing/models/__init__.py new file mode 100644 index 0000000..356991f --- /dev/null +++ b/webhook_outgoing/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_action_server +from . import webhook_logging diff --git a/webhook_outgoing/models/ir_action_server.py b/webhook_outgoing/models/ir_action_server.py new file mode 100644 index 0000000..d856938 --- /dev/null +++ b/webhook_outgoing/models/ir_action_server.py @@ -0,0 +1,218 @@ +# Copyright 2024 Hoang Tran . +# 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()) + + for record in records: + if self.delay_execution: + self.with_delay(eta=self.delay)._execute_webhook(record, None) + else: + self._execute_webhook(record, eval_context) + + return eval_context.get("action") + + def _execute_webhook(self, record, eval_context): + """ + Prepare request params/body by rendering template and send requests. + """ + self.ensure_one() + if eval_context is None: + eval_context = dict( + self._get_eval_context(action=self), record=record, records=record + ) + + try: + response, body = getattr( + 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) + else: + status_code = self._get_success_request_status_code(response) + if status_code != 200: + raise RetryableJobError + self._webhook_logging(body, response) + + def _get_webhook_headers(self): + self.ensure_one() + headers = json.loads(self.headers.strip()).copy() if self.headers else {} + return str(headers) + + def _prepare_data_for_post_graphql(self, template, record): + def get_escaped_field(record, field_name): + str_field = getattr(record, str(field_name), False) + if str_field and isinstance(str_field, str): + str_field = str_field.strip() + for esc_char, rep_char in zip(ESCAPE_CHARS, REPLACE_CHARS): + str_field = str_field.replace(esc_char, rep_char) + return str_field + + query = template.render(record=record, escape=get_escaped_field) + payload = json.dumps({"query": query, "variables": {}}) + return payload + + 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") + + 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") + + def _prepare_data_for_get(self, template, record, eval_context): + data = template.render(**dict(eval_context, record=record)) + return data.encode(encoding="utf-8") + + def _execute_webhook_get_request(self, record, eval_context): + self.ensure_one() + + 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( + endpoint, + params=(params or {}), + headers=headers, + timeout=DEFAULT_GET_TIMEOUT, + ) + + return r, params + + 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 = {} + + prepare_method = "_prepare_data_for_post_%s" % self.request_type + payload = getattr(self, prepare_method)(template, record, eval_context) + + r = requests.post( + endpoint, data=payload, headers=headers, timeout=DEFAULT_POST_TIMEOUT + ) + + return r, payload + + 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 + + if self.type == "graphql": + response_data = json.loads(response.text) if response.text else False + 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 + + elif self.type == "slack": + status_code = response.status_code + + return status_code + + 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, {}) + + def create_log(env, response): + vals = { + "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() + + create_log(env, response) + + def _handle_exception(self, response, exception, body): + try: + raise exception + except requests.exceptions.HTTPError: + _logger.error("HTTPError during request", exc_info=True) + except requests.exceptions.ConnectionError: + _logger.error("Error Connecting during request", exc_info=True) + except requests.exceptions.Timeout: + _logger.error("Connection Timeout", exc_info=True) + except requests.exceptions.RequestException: + _logger.error("Something wrong happened during request", exc_info=True) + except Exception: + # Final exception if none above catched + _logger.error( + "Internal exception happened during sending webhook request", + exc_info=True, + ) + finally: + self._webhook_logging(body, exception) diff --git a/webhook_outgoing/models/webhook_logging.py b/webhook_outgoing/models/webhook_logging.py new file mode 100644 index 0000000..f0b36a3 --- /dev/null +++ b/webhook_outgoing/models/webhook_logging.py @@ -0,0 +1,28 @@ +# Copyright 2024 Hoang Tran . +# 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() diff --git a/webhook_outgoing/readme/CONTRIBUTORS.rst b/webhook_outgoing/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..4d9e6f4 --- /dev/null +++ b/webhook_outgoing/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Hoang Tran diff --git a/webhook_outgoing/readme/DESCRIPTION.rst b/webhook_outgoing/readme/DESCRIPTION.rst new file mode 100644 index 0000000..17839c8 --- /dev/null +++ b/webhook_outgoing/readme/DESCRIPTION.rst @@ -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. diff --git a/webhook_outgoing/security/ir.model.access.csv b/webhook_outgoing/security/ir.model.access.csv new file mode 100644 index 0000000..e5b40b1 --- /dev/null +++ b/webhook_outgoing/security/ir.model.access.csv @@ -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 diff --git a/webhook_outgoing/static/description/icon.png b/webhook_outgoing/static/description/icon.png new file mode 100644 index 0000000..94b6104 Binary files /dev/null and b/webhook_outgoing/static/description/icon.png differ diff --git a/webhook_outgoing/static/description/index.html b/webhook_outgoing/static/description/index.html new file mode 100644 index 0000000..f12579b --- /dev/null +++ b/webhook_outgoing/static/description/index.html @@ -0,0 +1,429 @@ + + + + + +Outgoing Webhook + + + +
+

Outgoing Webhook

+ + +

Beta License: LGPL-3 OCA/webhook Translate me on Weblate Try me on Runboat

+

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

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Hoang Tran
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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 project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/webhook_outgoing/views/ir_action_server_views.xml b/webhook_outgoing/views/ir_action_server_views.xml new file mode 100644 index 0000000..c2a7931 --- /dev/null +++ b/webhook_outgoing/views/ir_action_server_views.xml @@ -0,0 +1,52 @@ + + + + inherited.server.action.custom.webhook.form + ir.actions.server + + + + + + + + + + + + + + + + + + + + +

Learn more template syntax at https://jinja.palletsprojects.com/en/3.1.x/templates/

+
+
+
+
+
diff --git a/webhook_outgoing/views/menus.xml b/webhook_outgoing/views/menus.xml new file mode 100644 index 0000000..7b1c9c4 --- /dev/null +++ b/webhook_outgoing/views/menus.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/webhook_outgoing/views/webhook_logging_views.xml b/webhook_outgoing/views/webhook_logging_views.xml new file mode 100644 index 0000000..9542acc --- /dev/null +++ b/webhook_outgoing/views/webhook_logging_views.xml @@ -0,0 +1,46 @@ + + + + webhook.logging.view.form + webhook.logging + +
+
+
+ + + + + + + + + + + + +
+
+
+ + webhook.logging.view.tree + webhook.logging + + + + + + + + + + + + + Webhook Loggings + webhook.logging + tree,form + [] + {} + +