From fa92a2190c6cee604bb899164646c248d3ba92bf Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Tue, 27 Aug 2024 03:26:30 +0700 Subject: [PATCH] [ADD] webhook_outgoing: send outgoing webhook requests --- .../odoo/addons/webhook_outgoing | 1 + setup/webhook_outgoing/setup.py | 6 + webhook_outgoing/README.rst | 363 +++++++++ webhook_outgoing/__init__.py | 1 + webhook_outgoing/__manifest__.py | 23 + webhook_outgoing/data/queue_data.xml | 7 + webhook_outgoing/helpers.py | 12 + webhook_outgoing/models/__init__.py | 2 + webhook_outgoing/models/ir_action_server.py | 308 ++++++++ webhook_outgoing/models/webhook_logging.py | 28 + webhook_outgoing/readme/CONFIGURE.md | 71 ++ webhook_outgoing/readme/CONTRIBUTORS.md | 1 + webhook_outgoing/readme/DESCRIPTION.md | 17 + webhook_outgoing/readme/INSTALL.md | 20 + webhook_outgoing/readme/USAGE.md | 119 +++ webhook_outgoing/security/ir.model.access.csv | 2 + webhook_outgoing/static/description/icon.png | Bin 0 -> 36480 bytes .../static/description/index.html | 713 ++++++++++++++++++ webhook_outgoing/tests/__init__.py | 1 + .../tests/test_outgoing_webhook.py | 422 +++++++++++ .../views/ir_action_server_views.xml | 52 ++ webhook_outgoing/views/menus.xml | 12 + .../views/webhook_logging_views.xml | 72 ++ 23 files changed, 2253 insertions(+) create mode 120000 setup/webhook_outgoing/odoo/addons/webhook_outgoing create mode 100644 setup/webhook_outgoing/setup.py create mode 100644 webhook_outgoing/README.rst create mode 100644 webhook_outgoing/__init__.py create mode 100644 webhook_outgoing/__manifest__.py create mode 100644 webhook_outgoing/data/queue_data.xml create mode 100644 webhook_outgoing/helpers.py create mode 100644 webhook_outgoing/models/__init__.py create mode 100644 webhook_outgoing/models/ir_action_server.py create mode 100644 webhook_outgoing/models/webhook_logging.py create mode 100644 webhook_outgoing/readme/CONFIGURE.md create mode 100644 webhook_outgoing/readme/CONTRIBUTORS.md create mode 100644 webhook_outgoing/readme/DESCRIPTION.md create mode 100644 webhook_outgoing/readme/INSTALL.md create mode 100644 webhook_outgoing/readme/USAGE.md create mode 100644 webhook_outgoing/security/ir.model.access.csv create mode 100644 webhook_outgoing/static/description/icon.png create mode 100644 webhook_outgoing/static/description/index.html create mode 100644 webhook_outgoing/tests/__init__.py create mode 100644 webhook_outgoing/tests/test_outgoing_webhook.py create mode 100644 webhook_outgoing/views/ir_action_server_views.xml create mode 100644 webhook_outgoing/views/menus.xml create mode 100644 webhook_outgoing/views/webhook_logging_views.xml 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..27cae95 --- /dev/null +++ b/webhook_outgoing/README.rst @@ -0,0 +1,363 @@ +================ +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 allows creating automations that send webhook/HTTP requests +to external systems. + +Key Features +------------ + +- **Integration with Automated Actions**: Extends Odoo's automated + actions with webhook capabilities +- **Jinja Template Support**: Render request bodies dynamically using + Jinja2 templating engine +- **Queue Job Support**: Execute webhooks asynchronously using queue_job + for better performance +- **Multiple Request Types**: Support for standard HTTP, GraphQL, and + Slack webhooks +- **Flexible Configuration**: Customize endpoints, headers, body + templates, and request methods +- **Request Logging**: Optional logging of webhook calls for debugging + and troubleshooting + +Use Cases +--------- + +- Send notifications to Slack, Discord, or other chat platforms when + records are created/updated +- Trigger external API calls when business events occur in Odoo +- Integrate with third-party services without custom code +- Synchronize data with external systems in real-time + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +Prerequisites +------------- + +This module requires the following dependencies: + +- ``queue_job`` - For asynchronous webhook execution. Please configure + ``queue_job`` properly before installation. +- Python ``jinja2`` library - For template rendering + +Installation Steps +------------------ + +1. Install the ``queue_job`` module from OCA/queue if you want to use + asynchronous execution: + + - Available at: https://github.com/OCA/queue + +2. If you haven't installed ``queue_job`` before installing this module, + you will need to restart the server to have ``queue_job`` loaded. + +Post-Installation +----------------- + +After installation, the module adds a new "Custom Webhook" action type +to Automated Actions. No additional configuration is required to start +using the module. + +Configuration +============= + +Navigate to **Settings > Technical > Automation > Automated Actions** to +configure webhook automations. + +Basic Configuration +------------------- + +1. **Create a New Automated Action** + + - Click "Create" + - Set the **Model** (e.g., Sale Order, Contact, etc.) + - Define the **Trigger** (On Creation, On Update, etc.) + - Set **Action To Do** to "Custom Webhook" + +2. **Configure Webhook Details** + + **Endpoint** + + - Enter the full URL of the webhook endpoint + - Example: ``https://hooks.slack.com/services/YOUR/WEBHOOK/URL`` + + **Request Method** + + - Choose between GET or POST + - Most webhooks use POST + + **Request Type** + + - **HTTP Request**: Standard REST API calls + - **GraphQL**: For GraphQL APIs + - **Slack**: Optimized for Slack webhooks + +3. **Headers Configuration** + + Add any required headers in JSON format: + + .. code:: json + + { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_TOKEN" + } + +4. **Body Template** + + Use Jinja2 syntax to create dynamic payloads: + + .. code:: jinja + + { + "id": {{ record.id }}, + "name": "{{ record.name }}", + "email": "{{ record.email }}", + "created_date": "{{ record.create_date }}" + } + + Available variables: + + - ``record``: The record that triggered the action + - Any field from the record model + +Asynchronous Execution +---------------------- + +Enable **Delay Execution** to run webhooks in the background using queue +jobs: + +- Check "Delay Execution" +- Set **Delay ETA (s)** to specify how many seconds to wait before + execution + +Webhook Logging +--------------- + +Enable **Log Calls** to track all webhook requests and responses for +debugging: + +- Check "Log Calls" +- View logs in **Settings > Technical > Webhook Logging** + +Domain Filters +-------------- + +Use the **Apply on** field to filter which records trigger the webhook +based on domain conditions. + +Security Considerations +----------------------- + +- Use HTTPS endpoints for secure communication +- Regularly review webhook logs for suspicious activity + +Usage +===== + +Example 1: Send Slack Notification on New Sale Order +---------------------------------------------------- + +1. Go to **Settings > Technical > Automation > Automated Actions** + +2. Click **Create** and configure: + + - **Name**: "Notify Slack on New Sale" + - **Model**: Sale Order + - **Trigger**: On Creation + - **Action To Do**: Custom Webhook + +3. Configure the webhook: + + - **Endpoint**: ``https://hooks.slack.com/services/YOUR/WEBHOOK/URL`` + - **Request Method**: POST + - **Request Type**: Slack + - **Headers**: + + .. code:: json + + { + "Content-Type": "application/json" + } + + - **Body Template**: + + .. code:: jinja + + { + "text": "New sale order created!", + "attachments": [{ + "color": "good", + "fields": [ + { + "title": "Order Number", + "value": "{{ record.name }}", + "short": true + }, + { + "title": "Customer", + "value": "{{ record.partner_id.name }}", + "short": true + }, + { + "title": "Amount", + "value": "${{ record.amount_total }}", + "short": true + } + ] + }] + } + +Example 2: POST to External API +------------------------------- + +Configure a webhook to send contact data to an external CRM: + +- **Endpoint**: ``https://api.example.com/contacts`` +- **Request Method**: POST +- **Request Type**: HTTP Request +- **Headers**: + + .. code:: json + + { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_API_TOKEN" + } + +- **Body Template**: + + .. code:: jinja + + { + "external_id": {{ record.id }}, + "first_name": "{{ record.name }}", + "email": "{{ record.email }}", + "phone": "{{ record.phone }}", + "company": "{{ record.company_id.name if record.company_id else '' }}" + } + +Example 3: GraphQL Query +------------------------ + +Send a GraphQL mutation when a product is updated: + +- **Endpoint**: ``https://api.example.com/graphql`` +- **Request Method**: POST +- **Request Type**: GraphQL +- **Body Template**: + + .. code:: jinja + + mutation { + updateProduct(input: { + id: {{ record.id }} + name: {{ record.name | escape }} + price: {{ record.list_price }} + }) { + id + statusCode + } + } + +Using Jinja2 Templates +---------------------- + +**Available Variables** + +- ``record``: The current record triggering the action +- Access related fields using dot notation: ``record.partner_id.name`` + +**Common Jinja2 Filters** + +.. code:: jinja + + {# String manipulation #} + {{ record.name | upper }} + {{ record.description | truncate(100) }} + + {# Default values #} + {{ record.email | default('no-email@example.com') }} + + {# Conditional rendering #} + {% if record.state == 'sale' %} + "status": "confirmed" + {% else %} + "status": "draft" + {% endif %} + + {# Escaping for GraphQL #} + {{ record.name | escape }} + +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 thhoang.tr@gmail.com + +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..4fdb83e --- /dev/null +++ b/webhook_outgoing/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2024 Hoang Tran . +# License LGPL-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/helpers.py b/webhook_outgoing/helpers.py new file mode 100644 index 0000000..8858566 --- /dev/null +++ b/webhook_outgoing/helpers.py @@ -0,0 +1,12 @@ +ESCAPE_CHARS = ['"', "\n", "\r", "\t", "\b", "\f"] +REPLACE_CHARS = ['\\"', "\\n", "\\r", "\\t", "\\b", "\\f"] + + +def get_escaped_value(record, field): + field_value = getattr(record, str(field), False) + + if field_value and isinstance(field_value, str): + field_value = field_value.strip() + for escape_char, replace_char in zip(ESCAPE_CHARS, REPLACE_CHARS): + field_value = field_value.replace(escape_char, replace_char) + return field_value 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..9579fcc --- /dev/null +++ b/webhook_outgoing/models/ir_action_server.py @@ -0,0 +1,308 @@ +# Copyright 2024 Hoang Tran . +# License LGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import json +import logging + +import requests +from jinja2 import BaseLoader, Environment + +from odoo import fields, models +from odoo.tools import ustr +from odoo.tools.safe_eval import safe_eval + +from ..helpers import get_escaped_value + +_logger = logging.getLogger(__name__) + + +DEFAULT_GET_TIMEOUT = 5 +DEFAULT_POST_TIMEOUT = 5 + +DEFAULT_BODY_TEMPLATE = """{# Available variables: + - record: record on which the action is triggered; may be void +#} +{ + "id": {{record.id}}, + "name": "{{record.name}}" +} +""" + + +class IrServerAction(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=DEFAULT_BODY_TEMPLATE) + 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. + :param dict eval_context: context used for execution + :return dict action: return current executed action for next execution + """ + 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 params for GET, or body for POST and send webhook request + + :param record: record which action is executed upon + :param eval_context: context used during action execution + """ + self.ensure_one() + + if eval_context is None: + eval_context = dict( + self._get_eval_context(action=self), record=record, records=record + ) + + response = body = None + try: + func = getattr(self, "_execute_webhook_%s_request" % self.request_method) + response, body = func(record, eval_context) + response.raise_for_status() + + status_code = self._get_body_status_code(response) + if status_code != 200: + raise requests.exceptions.HTTPError( + "Received response with status code %s" % status_code + ) + + except Exception as e: + # Try to get the body from the function if it was prepared before exception + self._handle_exception(response, e, body) + else: + self._webhook_logging(body, response) + + def _execute_webhook_get_request(self, record, eval_context): + """ + Execute outgoing webhook GET request + + :param record: record which action is executed upon + :param eval_context: context used during action execution + :return response: response object after executed webhook request + :return params: params used while sending webhook request, for logging + """ + self.ensure_one() + + endpoint = self.endpoint + headers = self._get_webhook_headers() + params = self._prepare_data_for_get(record, eval_context) + # Store params in case an exception occurs during the request + response = requests.get( + endpoint, + params=(params or {}), + headers=headers, + timeout=DEFAULT_GET_TIMEOUT, + ) + + return response, params + + def _execute_webhook_post_request(self, record, eval_context): + """ + Execute outgoing webhook POST request + + :param record: record which action is executed upon + :param eval_context: context used during action execution + :return response: response object after executed webhook request + :return payload: body/payload used while sending webhook request, for logging + """ + self.ensure_one() + + endpoint = self.endpoint + headers = self._get_webhook_headers() + payload = {} + + prepare_method = "_prepare_data_for_post_%s" % self.request_type + if not hasattr(self, prepare_method): + prepare_method = "_prepare_data_for_post_request" + + payload = getattr(self, prepare_method)(record, eval_context) + # Store payload in case an exception occurs during the request + + response = requests.post( + endpoint, data=payload, headers=headers, timeout=DEFAULT_POST_TIMEOUT + ) + + return response, payload + + def _get_webhook_headers(self): + """Prepare headers for outgoing webhook + + Accepts both JSON format and Python dict literals: + - JSON: {"Authorization": "Bearer token"} + - Python: {'Authorization': 'Bearer token'} + + :return dict headers: headers dictionary + """ + self.ensure_one() + headers_str = (self.headers or "").strip() + if not headers_str: + return {} + + try: + # Use safe_eval to handle both JSON and Python dict literals + headers = safe_eval(headers_str) + if not isinstance(headers, dict): + _logger.warning( + "Headers must be a dict, got %s. Returning empty dict.", + type(headers).__name__, + ) + return {} + return headers + except Exception as e: + _logger.error( + "Failed to parse headers '%s': %s. Returning empty dict.", + headers_str, + e, + ) + return {} + + def _prepare_data_for_get(self, record, eval_context): + """Render template as parameters to be passed down the request + :param record: record which action is executed upon + :param eval_context: context used during action execution + :return str params: parameters object in string format + """ + self.ensure_one() + template = Environment(loader=BaseLoader()).from_string(self.body_template) + data = template.render(**dict(eval_context, record=record)) + return data.encode(encoding="utf-8") + + def _prepare_data_for_post_request(self, record, eval_context): + """Render template as body to be passed down the request + :param record: record which action is executed upon + :param eval_context: context used during action execution + :return str params: body object in string format + """ + self.ensure_one() + template = Environment(loader=BaseLoader()).from_string(self.body_template) + data = template.render(**dict(eval_context, record=record)) + return data.encode(encoding="utf-8") + + def _prepare_data_for_post_graphql(self, record, eval_context): + """Render template as body specifically for GraphQL request in form of POST + :param record: record which action is executed upon + :param eval_context: context used during action execution + :return str params: body object in string format + """ + self.ensure_one() + + template = Environment(loader=BaseLoader()).from_string(self.body_template) + query = template.render( + **dict(eval_context, record=record, escape=get_escaped_value) + ) + payload = json.dumps({"query": query, "variables": {}}) + return payload + + def _get_body_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. + :param response: response object from request + :return int status_code: response status code + """ + status_code = response.status_code + + 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 + + return status_code + + def _webhook_logging(self, body, response): + """Log webhook requests for troubleshooting + :param str body: params or bodys used in webhook request + :param response: response of webhook request or exception + """ + if self.log_webhook_calls: + # Handle case where response might be an exception or None + if hasattr(response, "content"): + response_content = ustr(response.content) + status_code = getattr(response, "status_code", None) + else: + # Response is an exception or None + response_content = ustr(response) if response else "No response" + status_code = None + + vals = { + "webhook_type": "outgoing", + "webhook": "%s (%s)" % (self.name, self), + "endpoint": self.endpoint, + "headers": self.headers, + "body": ustr(body), + "response": response_content, + "status": status_code, + } + self.env["webhook.logging"].create(vals) + + def _handle_exception(self, response, exception, body): + """Hanlde exceptions while sending webhook requests + :param response: response of webhook request + :param exception: original exception raised while executing request + :param str body: params or bodies used in webhook request + """ + 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 caught + _logger.error( + "Internal exception happened during sending webhook request", + exc_info=True, + ) + finally: + # For HTTP errors, we have a response with status code + # For other errors (connection, timeout), we don't have a response + if isinstance(exception, requests.exceptions.HTTPError) and response: + self._webhook_logging(body, response) + else: + 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..dc640f8 --- /dev/null +++ b/webhook_outgoing/models/webhook_logging.py @@ -0,0 +1,28 @@ +# Copyright 2024 Hoang Tran . +# License LGPL-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.Integer() + body = fields.Text() + request = fields.Text() + response = fields.Text() diff --git a/webhook_outgoing/readme/CONFIGURE.md b/webhook_outgoing/readme/CONFIGURE.md new file mode 100644 index 0000000..651291b --- /dev/null +++ b/webhook_outgoing/readme/CONFIGURE.md @@ -0,0 +1,71 @@ +Navigate to **Settings > Technical > Automation > Automated Actions** to configure webhook automations. + +## Basic Configuration + +1. **Create a New Automated Action** + - Click "Create" + - Set the **Model** (e.g., Sale Order, Contact, etc.) + - Define the **Trigger** (On Creation, On Update, etc.) + - Set **Action To Do** to "Custom Webhook" + +2. **Configure Webhook Details** + + **Endpoint** + - Enter the full URL of the webhook endpoint + - Example: `https://hooks.slack.com/services/YOUR/WEBHOOK/URL` + + **Request Method** + - Choose between GET or POST + - Most webhooks use POST + + **Request Type** + - **HTTP Request**: Standard REST API calls + - **GraphQL**: For GraphQL APIs + - **Slack**: Optimized for Slack webhooks + +3. **Headers Configuration** + + Add any required headers in JSON format: + ```json + { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_TOKEN" + } + ``` + +4. **Body Template** + + Use Jinja2 syntax to create dynamic payloads: + ```jinja + { + "id": {{ record.id }}, + "name": "{{ record.name }}", + "email": "{{ record.email }}", + "created_date": "{{ record.create_date }}" + } + ``` + + Available variables: + - `record`: The record that triggered the action + - Any field from the record model + +## Asynchronous Execution + +Enable **Delay Execution** to run webhooks in the background using queue jobs: +- Check "Delay Execution" +- Set **Delay ETA (s)** to specify how many seconds to wait before execution + +## Webhook Logging + +Enable **Log Calls** to track all webhook requests and responses for debugging: +- Check "Log Calls" +- View logs in **Settings > Technical > Webhook Logging** + +## Domain Filters + +Use the **Apply on** field to filter which records trigger the webhook based on domain conditions. + +## Security Considerations + +* Use HTTPS endpoints for secure communication +* Regularly review webhook logs for suspicious activity diff --git a/webhook_outgoing/readme/CONTRIBUTORS.md b/webhook_outgoing/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..4d9e6f4 --- /dev/null +++ b/webhook_outgoing/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* Hoang Tran diff --git a/webhook_outgoing/readme/DESCRIPTION.md b/webhook_outgoing/readme/DESCRIPTION.md new file mode 100644 index 0000000..fff72dd --- /dev/null +++ b/webhook_outgoing/readme/DESCRIPTION.md @@ -0,0 +1,17 @@ +This module allows creating automations that send webhook/HTTP requests to external systems. + +## Key Features + +* **Integration with Automated Actions**: Extends Odoo's automated actions with webhook capabilities +* **Jinja Template Support**: Render request bodies dynamically using Jinja2 templating engine +* **Queue Job Support**: Execute webhooks asynchronously using queue_job for better performance +* **Multiple Request Types**: Support for standard HTTP, GraphQL, and Slack webhooks +* **Flexible Configuration**: Customize endpoints, headers, body templates, and request methods +* **Request Logging**: Optional logging of webhook calls for debugging and troubleshooting + +## Use Cases + +* Send notifications to Slack, Discord, or other chat platforms when records are created/updated +* Trigger external API calls when business events occur in Odoo +* Integrate with third-party services without custom code +* Synchronize data with external systems in real-time diff --git a/webhook_outgoing/readme/INSTALL.md b/webhook_outgoing/readme/INSTALL.md new file mode 100644 index 0000000..4a84d4f --- /dev/null +++ b/webhook_outgoing/readme/INSTALL.md @@ -0,0 +1,20 @@ +## Prerequisites + +This module requires the following dependencies: + +* `queue_job` - For asynchronous webhook execution. Please configure `queue_job` properly +before installation. +* Python `jinja2` library - For template rendering + +## Installation Steps + +1. Install the `queue_job` module from OCA/queue if you want to use asynchronous execution: + - Available at: https://github.com/OCA/queue + +3. If you haven't installed `queue_job` before installing this module, you will need to +restart the server to have `queue_job` loaded. + +## Post-Installation + +After installation, the module adds a new "Custom Webhook" action type to Automated Actions. +No additional configuration is required to start using the module. diff --git a/webhook_outgoing/readme/USAGE.md b/webhook_outgoing/readme/USAGE.md new file mode 100644 index 0000000..a547741 --- /dev/null +++ b/webhook_outgoing/readme/USAGE.md @@ -0,0 +1,119 @@ +## Example 1: Send Slack Notification on New Sale Order + +1. Go to **Settings > Technical > Automation > Automated Actions** +2. Click **Create** and configure: + - **Name**: "Notify Slack on New Sale" + - **Model**: Sale Order + - **Trigger**: On Creation + - **Action To Do**: Custom Webhook + +3. Configure the webhook: + - **Endpoint**: `https://hooks.slack.com/services/YOUR/WEBHOOK/URL` + - **Request Method**: POST + - **Request Type**: Slack + - **Headers**: + ```json + { + "Content-Type": "application/json" + } + ``` + - **Body Template**: + ```jinja + { + "text": "New sale order created!", + "attachments": [{ + "color": "good", + "fields": [ + { + "title": "Order Number", + "value": "{{ record.name }}", + "short": true + }, + { + "title": "Customer", + "value": "{{ record.partner_id.name }}", + "short": true + }, + { + "title": "Amount", + "value": "${{ record.amount_total }}", + "short": true + } + ] + }] + } + ``` + +## Example 2: POST to External API + +Configure a webhook to send contact data to an external CRM: + +- **Endpoint**: `https://api.example.com/contacts` +- **Request Method**: POST +- **Request Type**: HTTP Request +- **Headers**: + ```json + { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_API_TOKEN" + } + ``` +- **Body Template**: + ```jinja + { + "external_id": {{ record.id }}, + "first_name": "{{ record.name }}", + "email": "{{ record.email }}", + "phone": "{{ record.phone }}", + "company": "{{ record.company_id.name if record.company_id else '' }}" + } + ``` + +## Example 3: GraphQL Query + +Send a GraphQL mutation when a product is updated: + +- **Endpoint**: `https://api.example.com/graphql` +- **Request Method**: POST +- **Request Type**: GraphQL +- **Body Template**: + ```jinja + mutation { + updateProduct(input: { + id: {{ record.id }} + name: {{ record.name | escape }} + price: {{ record.list_price }} + }) { + id + statusCode + } + } + ``` + +## Using Jinja2 Templates + +**Available Variables** + +- `record`: The current record triggering the action +- Access related fields using dot notation: `record.partner_id.name` + +**Common Jinja2 Filters** + +```jinja +{# String manipulation #} +{{ record.name | upper }} +{{ record.description | truncate(100) }} + +{# Default values #} +{{ record.email | default('no-email@example.com') }} + +{# Conditional rendering #} +{% if record.state == 'sale' %} + "status": "confirmed" +{% else %} + "status": "draft" +{% endif %} + +{# Escaping for GraphQL #} +{{ record.name | escape }} +``` \ No newline at end of file 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 0000000000000000000000000000000000000000..94b6104f76963a5a36a4e76648bbf257b2b17c8e GIT binary patch literal 36480 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�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 + + + + +Outgoing Webhook + + + +
+

Outgoing Webhook

+ + +

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

+

This module allows creating automations that send webhook/HTTP requests +to external systems.

+
+

Key Features

+
    +
  • Integration with Automated Actions: Extends Odoo’s automated +actions with webhook capabilities
  • +
  • Jinja Template Support: Render request bodies dynamically using +Jinja2 templating engine
  • +
  • Queue Job Support: Execute webhooks asynchronously using queue_job +for better performance
  • +
  • Multiple Request Types: Support for standard HTTP, GraphQL, and +Slack webhooks
  • +
  • Flexible Configuration: Customize endpoints, headers, body +templates, and request methods
  • +
  • Request Logging: Optional logging of webhook calls for debugging +and troubleshooting
  • +
+
+
+

Use Cases

+
    +
  • Send notifications to Slack, Discord, or other chat platforms when +records are created/updated
  • +
  • Trigger external API calls when business events occur in Odoo
  • +
  • Integrate with third-party services without custom code
  • +
  • Synchronize data with external systems in real-time
  • +
+

Table of contents

+
+ +
+ +
+
+

Prerequisites

+

This module requires the following dependencies:

+
    +
  • queue_job - For asynchronous webhook execution. Please configure +queue_job properly before installation.
  • +
  • Python jinja2 library - For template rendering
  • +
+
+
+

Installation Steps

+
    +
  1. Install the queue_job module from OCA/queue if you want to use +asynchronous execution: +
  2. +
  3. If you haven’t installed queue_job before installing this module, +you will need to restart the server to have queue_job loaded.
  4. +
+
+
+

Post-Installation

+

After installation, the module adds a new “Custom Webhook” action type +to Automated Actions. No additional configuration is required to start +using the module.

+
+

Configuration

+

Navigate to Settings > Technical > Automation > Automated Actions to +configure webhook automations.

+
+
+
+

Basic Configuration

+
    +
  1. Create a New Automated Action

    +
      +
    • Click “Create”
    • +
    • Set the Model (e.g., Sale Order, Contact, etc.)
    • +
    • Define the Trigger (On Creation, On Update, etc.)
    • +
    • Set Action To Do to “Custom Webhook”
    • +
    +
  2. +
  3. Configure Webhook Details

    +

    Endpoint

    +
      +
    • Enter the full URL of the webhook endpoint
    • +
    • Example: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
    • +
    +

    Request Method

    +
      +
    • Choose between GET or POST
    • +
    • Most webhooks use POST
    • +
    +

    Request Type

    +
      +
    • HTTP Request: Standard REST API calls
    • +
    • GraphQL: For GraphQL APIs
    • +
    • Slack: Optimized for Slack webhooks
    • +
    +
  4. +
  5. Headers Configuration

    +

    Add any required headers in JSON format:

    +
    +{
    +    "Content-Type": "application/json",
    +    "Authorization": "Bearer YOUR_TOKEN"
    +}
    +
    +
  6. +
  7. Body Template

    +

    Use Jinja2 syntax to create dynamic payloads:

    +
    +{
    +    "id": {{ record.id }},
    +    "name": "{{ record.name }}",
    +    "email": "{{ record.email }}",
    +    "created_date": "{{ record.create_date }}"
    +}
    +
    +

    Available variables:

    +
      +
    • record: The record that triggered the action
    • +
    • Any field from the record model
    • +
    +
  8. +
+
+
+

Asynchronous Execution

+

Enable Delay Execution to run webhooks in the background using queue +jobs:

+
    +
  • Check “Delay Execution”
  • +
  • Set Delay ETA (s) to specify how many seconds to wait before +execution
  • +
+
+
+

Webhook Logging

+

Enable Log Calls to track all webhook requests and responses for +debugging:

+
    +
  • Check “Log Calls”
  • +
  • View logs in Settings > Technical > Webhook Logging
  • +
+
+
+

Domain Filters

+

Use the Apply on field to filter which records trigger the webhook +based on domain conditions.

+
+
+

Security Considerations

+
    +
  • Use HTTPS endpoints for secure communication
  • +
  • Regularly review webhook logs for suspicious activity
  • +
+
+

Usage

+
+
+
+

Example 1: Send Slack Notification on New Sale Order

+
    +
  1. Go to Settings > Technical > Automation > Automated Actions

    +
  2. +
  3. Click Create and configure:

    +
      +
    • Name: “Notify Slack on New Sale”
    • +
    • Model: Sale Order
    • +
    • Trigger: On Creation
    • +
    • Action To Do: Custom Webhook
    • +
    +
  4. +
  5. Configure the webhook:

    +
      +
    • Endpoint: https://hooks.slack.com/services/YOUR/WEBHOOK/URL

      +
    • +
    • Request Method: POST

      +
    • +
    • Request Type: Slack

      +
    • +
    • Headers:

      +
      +{
      +    "Content-Type": "application/json"
      +}
      +
      +
    • +
    • Body Template:

      +
      +{
      +    "text": "New sale order created!",
      +    "attachments": [{
      +        "color": "good",
      +        "fields": [
      +            {
      +                "title": "Order Number",
      +                "value": "{{ record.name }}",
      +                "short": true
      +            },
      +            {
      +                "title": "Customer",
      +                "value": "{{ record.partner_id.name }}",
      +                "short": true
      +            },
      +            {
      +                "title": "Amount",
      +                "value": "${{ record.amount_total }}",
      +                "short": true
      +            }
      +        ]
      +    }]
      +}
      +
      +
    • +
    +
  6. +
+
+
+

Example 2: POST to External API

+

Configure a webhook to send contact data to an external CRM:

+
    +
  • Endpoint: https://api.example.com/contacts

    +
  • +
  • Request Method: POST

    +
  • +
  • Request Type: HTTP Request

    +
  • +
  • Headers:

    +
    +{
    +    "Content-Type": "application/json",
    +    "Authorization": "Bearer YOUR_API_TOKEN"
    +}
    +
    +
  • +
  • Body Template:

    +
    +{
    +    "external_id": {{ record.id }},
    +    "first_name": "{{ record.name }}",
    +    "email": "{{ record.email }}",
    +    "phone": "{{ record.phone }}",
    +    "company": "{{ record.company_id.name if record.company_id else '' }}"
    +}
    +
    +
  • +
+
+
+

Example 3: GraphQL Query

+

Send a GraphQL mutation when a product is updated:

+
    +
  • Endpoint: https://api.example.com/graphql

    +
  • +
  • Request Method: POST

    +
  • +
  • Request Type: GraphQL

    +
  • +
  • Body Template:

    +
    +mutation {
    +    updateProduct(input: {
    +        id: {{ record.id }}
    +        name: {{ record.name | escape }}
    +        price: {{ record.list_price }}
    +    }) {
    +        id
    +        statusCode
    +    }
    +}
    +
    +
  • +
+
+
+

Using Jinja2 Templates

+

Available Variables

+
    +
  • record: The current record triggering the action
  • +
  • Access related fields using dot notation: record.partner_id.name
  • +
+

Common Jinja2 Filters

+
+{# String manipulation #}
+{{ record.name | upper }}
+{{ record.description | truncate(100) }}
+
+{# Default values #}
+{{ record.email | default('no-email@example.com') }}
+
+{# Conditional rendering #}
+{% if record.state == 'sale' %}
+    "status": "confirmed"
+{% else %}
+    "status": "draft"
+{% endif %}
+
+{# Escaping for GraphQL #}
+{{ record.name | escape }}
+
+
+

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/tests/__init__.py b/webhook_outgoing/tests/__init__.py new file mode 100644 index 0000000..5ec3548 --- /dev/null +++ b/webhook_outgoing/tests/__init__.py @@ -0,0 +1 @@ +from . import test_outgoing_webhook diff --git a/webhook_outgoing/tests/test_outgoing_webhook.py b/webhook_outgoing/tests/test_outgoing_webhook.py new file mode 100644 index 0000000..4a1a3a1 --- /dev/null +++ b/webhook_outgoing/tests/test_outgoing_webhook.py @@ -0,0 +1,422 @@ +import json +from unittest import mock + +import requests + +import odoo.tests +from odoo.tests.common import TransactionCase +from odoo.tools import ustr + + +@odoo.tests.tagged("post_install", "-at_install") +class TestOutgoingWebhook(TransactionCase): + def test_01_trigger_webhook(self): + test_automation = self.env["base.automation"].create( + { + "name": "Test outgoing webhook on updated partner", + "model_id": self.env.ref("base.model_res_partner").id, + "type": "ir.actions.server", + "trigger": "on_create_or_write", + "trigger_field_ids": [ + (6, 0, [self.env.ref("base.field_res_partner__name").id]) + ], + "state": "custom_webhook", + "endpoint": "https://httpbin.org/post", + "request_method": "post", + "request_type": "request", + "log_webhook_calls": True, + "body_template": '{"name": "{{record.name}}", "email": "{{record.email}}"}', + } + ) + test_partner_1 = self.env["res.partner"].create( + {"name": "Test Partner 1", "email": "test.partner1@test.example.com"} + ) + log = self.env["webhook.logging"].search( + [("webhook", "ilike", "Test outgoing webhook on updated partner")], limit=1 + ) + self.assertTrue(log) + self.assertEqual( + log.body, + ustr( + '{"name": "Test Partner 1", "email": "test.partner1@test.example.com"}' + ), + ) + + test_partner_1.name = "Test Partner 1-1" + log = self.env["webhook.logging"].search( + [("webhook", "ilike", "Test outgoing webhook on updated partner")], limit=1 + ) + self.assertEqual( + log.body, + ustr( + '{"name": "Test Partner 1-1", "email": "test.partner1@test.example.com"}' + ), + ) + + test_automation.unlink() + self.env["webhook.logging"].search([]).unlink() + + def test_02_render_request_body(self): + test_automation = self.env["base.automation"].create( + { + "name": "Test outgoing webhook", + "model_id": self.env.ref("base.model_res_partner").id, + "type": "ir.actions.server", + "trigger": "on_create_or_write", + "trigger_field_ids": [ + (6, 0, [self.env.ref("base.field_res_partner__name").id]) + ], + "state": "custom_webhook", + "endpoint": "https://httpbin.org/post", + "request_method": "post", + "request_type": "request", + "log_webhook_calls": False, + "active": True, + "body_template": '{"name": "{{record.name}}", "email": "{{record.email}}"}', + } + ) + webhook_action = test_automation.action_server_id + test_partner_2 = self.env["res.partner"].create( + {"name": "Test Partner 2", "email": "test.partner2@test.example.com"} + ) + body_string = webhook_action._prepare_data_for_post_request(test_partner_2, {}) + self.assertEqual( + body_string, + (b'{"name": "Test Partner 2", "email": "test.partner2@test.example.com"}'), + ) + test_automation.unlink() + + def test_03_timeout_error_handling(self): + """Test that timeout errors are properly logged""" + test_automation = self.env["base.automation"].create( + { + "name": "Test timeout webhook", + "model_id": self.env.ref("base.model_res_partner").id, + "type": "ir.actions.server", + "trigger": "on_create_or_write", + "trigger_field_ids": [ + (6, 0, [self.env.ref("base.field_res_partner__name").id]) + ], + "state": "custom_webhook", + "endpoint": "https://httpbin.org/delay/10", + "request_method": "post", + "request_type": "request", + "log_webhook_calls": True, + "body_template": '{"name": "{{record.name}}"}', + } + ) + + # Mock requests.post to raise a Timeout exception + # Also mock the logger to suppress ERROR logs during test + with mock.patch( + "odoo.addons.webhook_outgoing.models.ir_action_server.requests.post" + ) as mock_post, mock.patch( + "odoo.addons.webhook_outgoing.models.ir_action_server._logger" + ): + mock_post.side_effect = requests.exceptions.Timeout("Connection timeout") + + # Create a partner to trigger the webhook + self.env["res.partner"].create( + {"name": "Test Timeout Partner", "email": "timeout@test.example.com"} + ) + + # Verify that the webhook log was created with timeout error + log = self.env["webhook.logging"].search( + [("webhook", "ilike", "Test timeout webhook")], limit=1 + ) + self.assertTrue(log) + self.assertIn("timeout", log.response.lower()) + self.assertFalse(log.status) + + test_automation.unlink() + self.env["webhook.logging"].search([]).unlink() + + def test_04_connection_error_handling(self): + """Test that connection errors are properly logged""" + test_automation = self.env["base.automation"].create( + { + "name": "Test connection error webhook", + "model_id": self.env.ref("base.model_res_partner").id, + "type": "ir.actions.server", + "trigger": "on_create_or_write", + "trigger_field_ids": [ + (6, 0, [self.env.ref("base.field_res_partner__name").id]) + ], + "state": "custom_webhook", + "endpoint": "https://invalid-domain-that-does-not-exist.com/webhook", + "request_method": "post", + "request_type": "request", + "log_webhook_calls": True, + "body_template": '{"name": "{{record.name}}"}', + } + ) + + # Mock requests.post to raise a ConnectionError + # Also mock the logger to suppress ERROR logs during test + with mock.patch( + "odoo.addons.webhook_outgoing.models.ir_action_server.requests.post" + ) as mock_post, mock.patch( + "odoo.addons.webhook_outgoing.models.ir_action_server._logger" + ): + mock_post.side_effect = requests.exceptions.ConnectionError( + "Connection failed" + ) + + # Create a partner to trigger the webhook + self.env["res.partner"].create( + { + "name": "Test Connection Partner", + "email": "connection@test.example.com", + } + ) + + # Verify that the webhook log was created with connection error + log = self.env["webhook.logging"].search( + [("webhook", "ilike", "Test connection error webhook")], limit=1 + ) + self.assertTrue(log) + self.assertIn("Connection", log.response) + self.assertFalse(log.status) + + test_automation.unlink() + self.env["webhook.logging"].search([]).unlink() + + def test_05_http_error_handling(self): + """Test that HTTP errors (4xx, 5xx) are properly logged""" + test_automation = self.env["base.automation"].create( + { + "name": "Test HTTP error webhook", + "model_id": self.env.ref("base.model_res_partner").id, + "type": "ir.actions.server", + "trigger": "on_create_or_write", + "trigger_field_ids": [ + (6, 0, [self.env.ref("base.field_res_partner__name").id]) + ], + "state": "custom_webhook", + "endpoint": "https://httpbin.org/status/500", + "request_method": "post", + "request_type": "request", + "log_webhook_calls": True, + "body_template": '{"name": "{{record.name}}"}', + } + ) + + # Mock requests.post to return a 500 error + mock_response = mock.Mock() + mock_response.status_code = 500 + mock_response.content = b"Internal Server Error" + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + "500 Server Error" + ) + + # Mock the logger to suppress ERROR logs during test + with mock.patch( + "odoo.addons.webhook_outgoing.models.ir_action_server.requests.post", + return_value=mock_response, + ), mock.patch("odoo.addons.webhook_outgoing.models.ir_action_server._logger"): + # Create a partner to trigger the webhook + self.env["res.partner"].create( + { + "name": "Test HTTP Error Partner", + "email": "httperror@test.example.com", + } + ) + + # Verify that the webhook log was created with HTTP error + log = self.env["webhook.logging"].search( + [("webhook", "ilike", "Test HTTP error webhook")], limit=1 + ) + self.assertTrue(log) + self.assertEqual(log.status, 500) + + test_automation.unlink() + self.env["webhook.logging"].search([]).unlink() + + def test_06_get_request(self): + """Test GET request webhook""" + test_automation = self.env["base.automation"].create( + { + "name": "Test GET webhook", + "model_id": self.env.ref("base.model_res_partner").id, + "type": "ir.actions.server", + "trigger": "on_create_or_write", + "trigger_field_ids": [ + (6, 0, [self.env.ref("base.field_res_partner__name").id]) + ], + "state": "custom_webhook", + "endpoint": "https://httpbin.org/get", + "request_method": "get", + "request_type": "request", + "log_webhook_calls": True, + "body_template": '{"name": "{{record.name}}"}', + } + ) + + # Mock requests.get to return a 200 response + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.content = b'{"success": true}' + mock_response.raise_for_status.return_value = None + + with mock.patch( + "odoo.addons.webhook_outgoing.models.ir_action_server.requests.get", + return_value=mock_response, + ): + # Create a partner to trigger the webhook + self.env["res.partner"].create( + {"name": "Test GET Partner", "email": "get@test.example.com"} + ) + + # Verify that the webhook log was created + log = self.env["webhook.logging"].search( + [("webhook", "ilike", "Test GET webhook")], limit=1 + ) + self.assertTrue(log) + self.assertEqual(log.status, 200) + + test_automation.unlink() + self.env["webhook.logging"].search([]).unlink() + + def test_07_graphql_request(self): + """Test GraphQL request webhook""" + test_automation = self.env["base.automation"].create( + { + "name": "Test GraphQL webhook", + "model_id": self.env.ref("base.model_res_partner").id, + "type": "ir.actions.server", + "trigger": "on_create_or_write", + "trigger_field_ids": [ + (6, 0, [self.env.ref("base.field_res_partner__name").id]) + ], + "state": "custom_webhook", + "endpoint": "https://httpbin.org/post", + "request_method": "post", + "request_type": "graphql", + "log_webhook_calls": True, + "body_template": """query { + partner(id: {{record.id}}) { + name + } + }""", + } + ) + + webhook_action = test_automation.action_server_id + test_partner = self.env["res.partner"].create( + {"name": "Test Partner GraphQL", "email": "test.graphql@example.com"} + ) + + # Verify the GraphQL payload is properly formatted + body = webhook_action._prepare_data_for_post_graphql(test_partner, {}) + self.assertIn('"query":', body) + self.assertIn("partner", body) + + test_automation.unlink() + self.env["webhook.logging"].search([]).unlink() + + def test_08_custom_headers(self): + """Test webhook with custom headers - JSON format""" + headers = { + "Authorization": "Bearer test-token", + "Content-Type": "application/json", + } + test_automation = self.env["base.automation"].create( + { + "name": "Test custom headers webhook", + "model_id": self.env.ref("base.model_res_partner").id, + "type": "ir.actions.server", + "trigger": "on_create_or_write", + "trigger_field_ids": [ + (6, 0, [self.env.ref("base.field_res_partner__name").id]) + ], + "state": "custom_webhook", + "endpoint": "https://httpbin.org/post", + "request_method": "post", + "request_type": "request", + "log_webhook_calls": True, + "headers": json.dumps(headers), + "body_template": '{"name": "{{record.name}}"}', + } + ) + + webhook_action = test_automation.action_server_id + headers_dict = webhook_action._get_webhook_headers() + self.assertIsInstance(headers_dict, dict) + self.assertEqual(headers_dict.get("Authorization"), "Bearer test-token") + self.assertEqual(headers_dict.get("Content-Type"), "application/json") + + test_automation.unlink() + + def test_09_headers_python_dict_format(self): + """Test webhook headers with Python dict literal (single quotes)""" + webhook_action = self.env["ir.actions.server"].create( + { + "name": "Test Python dict headers", + "model_id": self.env.ref("base.model_res_partner").id, + "state": "custom_webhook", + "endpoint": "https://httpbin.org/post", + "headers": "{'Authorization': 'Bearer token', 'X-Custom': 'value'}", + } + ) + + headers_dict = webhook_action._get_webhook_headers() + self.assertIsInstance(headers_dict, dict) + self.assertEqual(headers_dict.get("Authorization"), "Bearer token") + self.assertEqual(headers_dict.get("X-Custom"), "value") + + def test_10_headers_empty_cases(self): + """Test webhook headers with empty/whitespace values""" + # Test with empty string + webhook_action = self.env["ir.actions.server"].create( + { + "name": "Test empty headers", + "model_id": self.env.ref("base.model_res_partner").id, + "state": "custom_webhook", + "endpoint": "https://httpbin.org/post", + "headers": "", + } + ) + headers_dict = webhook_action._get_webhook_headers() + self.assertEqual(headers_dict, {}) + + # Test with whitespace only + webhook_action.headers = " " + headers_dict = webhook_action._get_webhook_headers() + self.assertEqual(headers_dict, {}) + + # Test with default value + webhook_action.headers = "{}" + headers_dict = webhook_action._get_webhook_headers() + self.assertEqual(headers_dict, {}) + + def test_11_headers_invalid_format(self): + """Test webhook headers with invalid format returns empty dict""" + webhook_action = self.env["ir.actions.server"].create( + { + "name": "Test invalid headers", + "model_id": self.env.ref("base.model_res_partner").id, + "state": "custom_webhook", + "endpoint": "https://httpbin.org/post", + "headers": "not a valid dict or json", + } + ) + + # Should return empty dict and log error + headers_dict = webhook_action._get_webhook_headers() + self.assertEqual(headers_dict, {}) + + def test_12_headers_non_dict_value(self): + """Test webhook headers with non-dict value returns empty dict""" + webhook_action = self.env["ir.actions.server"].create( + { + "name": "Test non-dict headers", + "model_id": self.env.ref("base.model_res_partner").id, + "state": "custom_webhook", + "endpoint": "https://httpbin.org/post", + "headers": "['list', 'not', 'dict']", + } + ) + + # Should return empty dict and log warning + headers_dict = webhook_action._get_webhook_headers() + self.assertEqual(headers_dict, {}) 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..120b8d7 --- /dev/null +++ b/webhook_outgoing/views/webhook_logging_views.xml @@ -0,0 +1,72 @@ + + + + webhook.logging.view.form + webhook.logging + +
+
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ + webhook.logging.view.tree + webhook.logging + + + + + + + + + + + + + Webhook Loggings + webhook.logging + tree,form + [] + {} + +