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 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..8a66eb6 --- /dev/null +++ b/webhook_outgoing/static/description/index.html @@ -0,0 +1,713 @@ + + + + + +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 + [] + {} + +