From bf36bd383a27e906ef5ef9576a3098bc5b1bea1b Mon Sep 17 00:00:00 2001 From: git_admin Date: Sun, 3 May 2026 18:54:56 +0000 Subject: [PATCH] Tower: upload cetmix_tower_webhook 18.0.1.0.1 (was 18.0.1.0.1, via marketplace) --- addons/cetmix_tower_webhook/README.rst | 180 ++++ addons/cetmix_tower_webhook/__init__.py | 2 + addons/cetmix_tower_webhook/__manifest__.py | 28 + .../controllers/__init__.py | 4 + .../cetmix_tower_webhook/controllers/main.py | 250 ++++++ .../cetmix_tower_webhook/demo/demo_data.xml | 36 + .../i18n/cetmix_tower_webhook.pot | 685 +++++++++++++++ addons/cetmix_tower_webhook/i18n/it.po | 806 ++++++++++++++++++ .../cetmix_tower_webhook/models/__init__.py | 9 + .../cetmix_tower_webhook/models/constants.py | 70 ++ .../models/cx_tower_variable.py | 85 ++ .../models/cx_tower_webhook.py | 221 +++++ .../models/cx_tower_webhook_authenticator.py | 383 +++++++++ .../models/cx_tower_webhook_eval_mixin.py | 201 +++++ .../models/cx_tower_webhook_log.py | 197 +++++ .../models/res_config_settings.py | 13 + addons/cetmix_tower_webhook/pyproject.toml | 3 + .../cetmix_tower_webhook/readme/CONFIGURE.md | 58 ++ addons/cetmix_tower_webhook/readme/CONTEXT.md | 2 + .../readme/DESCRIPTION.md | 5 + addons/cetmix_tower_webhook/readme/HISTORY.md | 3 + addons/cetmix_tower_webhook/readme/USAGE.md | 3 + .../readme/newsfragments/.gitkeep | 0 .../security/ir.model.access.csv | 4 + .../static/description/banner.png | Bin 0 -> 33541 bytes .../static/description/icon.png | Bin 0 -> 22128 bytes .../static/description/index.html | 535 ++++++++++++ addons/cetmix_tower_webhook/tests/__init__.py | 7 + addons/cetmix_tower_webhook/tests/common.py | 38 + .../tests/test_cx_tower_webhook.py | 154 ++++ .../test_cx_tower_webhook_authenticator.py | 143 ++++ .../tests/test_cx_tower_webhook_log.py | 68 ++ .../tests/test_webhook_controller.py | 608 +++++++++++++ .../views/cx_tower_variable_views.xml | 40 + .../cx_tower_webhook_authenticator_views.xml | 127 +++ .../views/cx_tower_webhook_log_views.xml | 183 ++++ .../views/cx_tower_webhook_views.xml | 191 +++++ .../cetmix_tower_webhook/views/menuitems.xml | 27 + .../views/res_config_settings_views.xml | 24 + 39 files changed, 5393 insertions(+) create mode 100644 addons/cetmix_tower_webhook/README.rst create mode 100644 addons/cetmix_tower_webhook/__init__.py create mode 100644 addons/cetmix_tower_webhook/__manifest__.py create mode 100644 addons/cetmix_tower_webhook/controllers/__init__.py create mode 100644 addons/cetmix_tower_webhook/controllers/main.py create mode 100644 addons/cetmix_tower_webhook/demo/demo_data.xml create mode 100644 addons/cetmix_tower_webhook/i18n/cetmix_tower_webhook.pot create mode 100644 addons/cetmix_tower_webhook/i18n/it.po create mode 100644 addons/cetmix_tower_webhook/models/__init__.py create mode 100644 addons/cetmix_tower_webhook/models/constants.py create mode 100644 addons/cetmix_tower_webhook/models/cx_tower_variable.py create mode 100644 addons/cetmix_tower_webhook/models/cx_tower_webhook.py create mode 100644 addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py create mode 100644 addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py create mode 100644 addons/cetmix_tower_webhook/models/cx_tower_webhook_log.py create mode 100644 addons/cetmix_tower_webhook/models/res_config_settings.py create mode 100644 addons/cetmix_tower_webhook/pyproject.toml create mode 100644 addons/cetmix_tower_webhook/readme/CONFIGURE.md create mode 100644 addons/cetmix_tower_webhook/readme/CONTEXT.md create mode 100644 addons/cetmix_tower_webhook/readme/DESCRIPTION.md create mode 100644 addons/cetmix_tower_webhook/readme/HISTORY.md create mode 100644 addons/cetmix_tower_webhook/readme/USAGE.md create mode 100644 addons/cetmix_tower_webhook/readme/newsfragments/.gitkeep create mode 100644 addons/cetmix_tower_webhook/security/ir.model.access.csv create mode 100644 addons/cetmix_tower_webhook/static/description/banner.png create mode 100644 addons/cetmix_tower_webhook/static/description/icon.png create mode 100644 addons/cetmix_tower_webhook/static/description/index.html create mode 100644 addons/cetmix_tower_webhook/tests/__init__.py create mode 100644 addons/cetmix_tower_webhook/tests/common.py create mode 100644 addons/cetmix_tower_webhook/tests/test_cx_tower_webhook.py create mode 100644 addons/cetmix_tower_webhook/tests/test_cx_tower_webhook_authenticator.py create mode 100644 addons/cetmix_tower_webhook/tests/test_cx_tower_webhook_log.py create mode 100644 addons/cetmix_tower_webhook/tests/test_webhook_controller.py create mode 100644 addons/cetmix_tower_webhook/views/cx_tower_variable_views.xml create mode 100644 addons/cetmix_tower_webhook/views/cx_tower_webhook_authenticator_views.xml create mode 100644 addons/cetmix_tower_webhook/views/cx_tower_webhook_log_views.xml create mode 100644 addons/cetmix_tower_webhook/views/cx_tower_webhook_views.xml create mode 100644 addons/cetmix_tower_webhook/views/menuitems.xml create mode 100644 addons/cetmix_tower_webhook/views/res_config_settings_views.xml diff --git a/addons/cetmix_tower_webhook/README.rst b/addons/cetmix_tower_webhook/README.rst new file mode 100644 index 0000000..7a949cd --- /dev/null +++ b/addons/cetmix_tower_webhook/README.rst @@ -0,0 +1,180 @@ +==================== +Cetmix Tower Webhook +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0cbd53756d84f0d2d69c20ef40981ee7fece166a6fc228bff4d56346bc5cbd51 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github + :target: https://github.com/cetmix/cetmix-tower/tree/18.0/cetmix_tower_webhook + :alt: cetmix/cetmix-tower + +|badge1| |badge2| |badge3| + +This module implements incoming webhooks for `Cetmix +Tower `__. Webhooks are authorised using +customisable authenticators which can be pre-configured and reused +across multiple webhooks. Webhooks and authenticators can be exported +and imported using YAML format, which makes them easily sharable. + +This module is a part of Cetmix Tower, however it can be used to manage +any other odoo applications. + +Please refer to the `official +documentation `__ for detailed information. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Although Odoo has native support of webhooks staring 17.0, they still +have some limitations. Another option is the OCA 'endpoint' module which +although is more flexible still makes it usable with Cetmix Tower more +complicated. + +Configuration +============= + +Configure an Authenticator +-------------------------- + +**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to +configure authenticators.** + +- Go to "Cetmix Tower > Settings > Automation > Webhook Authenticators" + and click "New". + +**Complete the following fields:** + +- Name. Authenticator name +- Reference. Unique reference. Leave this field blank to auto generate + it +- Code. Code that is used to authenticate the request. You can use all + Cetmix Tower - Python command variables except for the server​ plus the + following webhook specific ones: +- headers: dictionary that contains the request headers +- raw_data: string with the raw HTTP request body +- payload: dictionary that contains the JSON payload or the GET + parameters of the request + +**The code returns the result​ variable in the following format:** + +.. code:: python + + result = {"allowed": , "http_code": , "message": } + +eg: + +.. code:: python + + result = {"allowed": True} + result = {"allowed": False, "http_code": 403, "message": "Sorry..."} + +Configure a Webhook +------------------- + +**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to +configure webhooks.** + +- Go to "Cetmix Tower > Settings > Automation > Webhooks" and click + "New". + +**Complete the following fields:** + +- Enabled. Uncheck this field to disable the webhook without deleting it +- Name. Authenticator name +- Reference. Unique reference. Leave this field blank to auto generate + it +- Authenticator. Select an Authenticator used for this webhook +- Endpoint. Webhook endpoint. The complete webhook URL will be + /cetmix_tower_webhooks/​ +- Run as User. Select a user to run the webhook on behalf of. CAREFUL! + You must realize and understand what you are doing, including all the + possible consequences when selecting a specific user. +- Code. Code that processes the request. You can use all Cetmix Tower + Python command variables (except for the server) plus the following + webhook-specific one: + + - headers: dictionary that contains the request headers + - payload: dictionary that contains the JSON payload or the GET + parameters of the request + +Webhook code returns a result using the Cetmix Tower Python command +pattern: + +.. code:: python + + result = {"exit_code": , "message": } + +**To configure the time for which the webhook call logs are stored:** + +- Go to "Cetmix Tower > Settings > General Settings" +- Put a number of days into the "Keep Webhook Logs for (days)" field. + Default value is 30. + +Please refer to the `official +documentation `__ for detailed configuration +instructions. + +Usage +===== + +When a request is received, Cetmix Tower will search for the webhook +with the matching endpoint and authenticate the request using the +selected authenticator. In case of successful authentication webhook +code is run. Each webhook call is logged. Logs are available under the +"Cetmix Tower > Logs > Webhook Calls" menu or under the "Logs" button +directly in the Webhook. + +Please refer to the `official +documentation `__ for detailed usage +instructions. + +Changelog +========= + +18.0.1.0.1 (2025-12-17) +----------------------- + +- Features: Improve search views, implement the search panel for + selected views. (5139) + +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 +------- + +* Cetmix + +Maintainers +----------- + +This module is part of the `cetmix/cetmix-tower `_ project on GitHub. + +You are welcome to contribute. diff --git a/addons/cetmix_tower_webhook/__init__.py b/addons/cetmix_tower_webhook/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/addons/cetmix_tower_webhook/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/addons/cetmix_tower_webhook/__manifest__.py b/addons/cetmix_tower_webhook/__manifest__.py new file mode 100644 index 0000000..4d5e996 --- /dev/null +++ b/addons/cetmix_tower_webhook/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright Cetmix OÜ 2025 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Cetmix Tower Webhook", + "summary": "Webhook implementation for Cetmix Tower", + "version": "18.0.1.0.1", + "development_status": "Beta", + "category": "Productivity", + "website": "https://tower.cetmix.com", + "live_test_url": "https://tower.cetmix.com/download", + "images": ["static/description/banner.png"], + "author": "Cetmix", + "license": "AGPL-3", + "installable": True, + "depends": ["cetmix_tower_yaml"], + "data": [ + "security/ir.model.access.csv", + "views/cx_tower_webhook_authenticator_views.xml", + "views/cx_tower_webhook_log_views.xml", + "views/cx_tower_webhook_views.xml", + "views/cx_tower_variable_views.xml", + "views/res_config_settings_views.xml", + "views/menuitems.xml", + ], + "demo": [ + "demo/demo_data.xml", + ], +} diff --git a/addons/cetmix_tower_webhook/controllers/__init__.py b/addons/cetmix_tower_webhook/controllers/__init__.py new file mode 100644 index 0000000..4cc36e1 --- /dev/null +++ b/addons/cetmix_tower_webhook/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import main diff --git a/addons/cetmix_tower_webhook/controllers/main.py b/addons/cetmix_tower_webhook/controllers/main.py new file mode 100644 index 0000000..6325e03 --- /dev/null +++ b/addons/cetmix_tower_webhook/controllers/main.py @@ -0,0 +1,250 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +import logging + +from odoo import http +from odoo.http import Response, request + +_logger = logging.getLogger(__name__) + + +class CetmixTowerWebhookController(http.Controller): + """ + Handles incoming requests to Tower webhooks. + """ + + @http.route( + ["/cetmix_tower_webhooks/"], + type="http", + auth="public", + methods=["POST", "GET"], + csrf=False, + save_session=False, + ) + def cetmix_webhook(self, endpoint, **kwargs): + """ + Process an incoming webhook request. + + Workflow: + 1. Extract request headers, body, and HTTP method. + 2. Match the request against a registered webhook. + 3. Authenticate the request if required. + 4. Execute the webhook code. + 5. Log the request and return the response. + + Args: + endpoint (str): The requested webhook endpoint. + **kwargs: Additional request parameters. + + Returns: + Response: HTTP JSON response containing the result message. + """ + # Step 1: Extract request data + headers = self._extract_webhook_request_headers() + raw_data = self._extract_webhook_request_raw_data() + http_method = request.httprequest.method.lower() + + # Step 2. Find webhook + webhook = ( + request.env["cx.tower.webhook"] + .sudo() + .search( + [ + ("endpoint", "=", endpoint), + ("method", "=", http_method), + ("active", "=", True), + ], + ) + ) + payload = self._extract_webhook_request_payload(webhook) + + log_model = request.env["cx.tower.webhook.log"].sudo() + log_values = log_model._prepare_values( + webhook=webhook, + endpoint=endpoint, + request_method=http_method, + request_payload=payload, + request_headers=headers, + authentication_status="not_required", + code_status="skipped", + ) + + if not webhook: + log_values.update( + { + "authentication_status": "failed", + "http_status": 404, + } + ) + return self._finalize_webhook_response( + message="Webhook not found", + error_message="Webhook not found", + **log_values, + ) + + # Step 3. Authenticate + auth_status, auth_error, http_auth_code = "success", None, 200 + if webhook.authenticator_id: + if not webhook.authenticator_id.is_ip_allowed(self._get_remote_addr()): + auth_status, auth_error, http_auth_code = ( + "failed", + "Address not allowed", + 403, + ) + log_values.update( + { + "error_message": auth_error, + "http_status": http_auth_code, + "authentication_status": auth_status, + } + ) + return self._finalize_webhook_response( + message=auth_error, + **log_values, + ) + + try: + with request.env.cr.savepoint(): + auth_result = webhook.authenticator_id.sudo().authenticate( + headers=headers, + raw_data=raw_data, + payload=payload, + ) + if not auth_result.get("allowed"): + raise Exception( + auth_result.get("message", "Authentication not allowed") + ) + except Exception as e: + auth_status, auth_error, http_auth_code = "failed", str(e), 403 + else: + auth_status = "not_required" + + if auth_status == "failed": + # Authentication failed + log_values.update( + { + "error_message": auth_error, + "http_status": http_auth_code, + "authentication_status": auth_status, + } + ) + return self._finalize_webhook_response( + message=auth_error, + **log_values, + ) + + # Step 4. Execute webhook code + code_status, error_message, http_code, message = "success", None, 200, "OK" + try: + with request.env.cr.savepoint(): + code_result = webhook.execute(payload, headers=headers) + if code_result.get("exit_code") != 0: + raise Exception(code_result.get("message")) + message = code_result.get("message") or "OK" + except Exception as e: + code_status, error_message, http_code, message = "failed", str(e), 500, None + + # Step 5. Update log + log_values.update( + { + "code_status": code_status, + "error_message": error_message, + "http_status": http_code, + "result_message": message, + "authentication_status": auth_status, + } + ) + + return self._finalize_webhook_response( + message=message or error_message or "", **log_values + ) + + def _extract_webhook_request_payload(self, webhook): + """ + Extract the request payload depending on HTTP method and content type. + + Args: + webhook (cx.tower.webhook): Webhook record with configuration + (may be empty). + + Returns: + dict: Parsed payload as a dictionary. Empty if parsing fails. + """ + http_method = request.httprequest.method + try: + if http_method.upper() == "POST": + content_type = webhook.content_type if webhook else "json" + return self._get_payload_by_content_type(content_type) + elif http_method.upper() == "GET": + return request.httprequest.args.to_dict(flat=True) + except Exception: + return {} + return {} + + def _get_payload_by_content_type(self, content_type): + """ + Return the request payload for POST requests according to content type. + + Args: + content_type (str): Payload format, e.g. "json" or "form". + + Returns: + dict: Parsed payload as a dictionary. + """ + if content_type == "form": + return request.httprequest.form.to_dict(flat=True) + data = request.httprequest.data + return json.loads(data or "{}") if data else {} + + def _extract_webhook_request_headers(self): + """ + Extract request headers. + + Returns: + dict: Request headers as a dictionary. + """ + return dict(request.httprequest.headers) + + def _extract_webhook_request_raw_data(self): + """ + Return raw request body. + + Returns: + bytes: Raw HTTP request body. + """ + return request.httprequest.data + + def _finalize_webhook_response(self, message, **kwargs): + """ + Create a log entry and return final HTTP response. + + Args: + message (str): Response message text. + **kwargs: Log values for `cx.tower.webhook.log`. + + Returns: + Response: HTTP JSON response with message and status code. + """ + try: + with request.env.cr.savepoint(): + request.env["cx.tower.webhook.log"].sudo().create_from_call(**kwargs) + except Exception: + # don't break controller if logging fails + _logger.error("Failed to create log entry", exc_info=True) + + return Response( + status=kwargs.get("http_status") or 200, + response=json.dumps({"message": message or ""}), + content_type="application/json", + ) + + def _get_remote_addr(self): + """ + Return the remote IP address of the current request. + + Returns: + str: Remote client IP address. + """ + return request.httprequest.remote_addr diff --git a/addons/cetmix_tower_webhook/demo/demo_data.xml b/addons/cetmix_tower_webhook/demo/demo_data.xml new file mode 100644 index 0000000..fd51526 --- /dev/null +++ b/addons/cetmix_tower_webhook/demo/demo_data.xml @@ -0,0 +1,36 @@ + + + + + Demo Webhook Authenticator 1 + demo_webhook_authenticator_1 + 192.168.1.10,192.168.2.0/24,10.0.0.1 + result = {"allowed": True} + + + + Demo Webhook Authenticator 2 + demo_webhook_authenticator_2 + result = {"allowed": False, "http_code": 403, "message": "Sorry..."} + + + + + Demo Webhook 1 + demo_webhook_1 + + demo_webhook_1 + result = {"exit_code": 0, "message": "OK"} + + + + Demo Webhook 2 + demo_webhook_2 + + demo_webhook_2 + get + result = {"exit_code": 0, "message": "OK"} + + diff --git a/addons/cetmix_tower_webhook/i18n/cetmix_tower_webhook.pot b/addons/cetmix_tower_webhook/i18n/cetmix_tower_webhook.pot new file mode 100644 index 0000000..87813c3 --- /dev/null +++ b/addons/cetmix_tower_webhook/i18n/cetmix_tower_webhook.pot @@ -0,0 +1,685 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_webhook +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: cetmix_tower_webhook +#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_action +msgid "Add a new webhook" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_authenticator_action +msgid "Add a new webhook authenticator" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "All" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses +msgid "Allowed IPs" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +msgid "Auth Failed" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +msgid "Auth Status" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status +msgid "Authentication Status" +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0 +msgid "Authentication code error: %s" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authenticator_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "Authenticator" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__reference +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference +msgid "" +"Can contain English letters, digits and '_'. Leave blank to autogenerate" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_variable +msgid "Cetmix Tower Variable" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "Code" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +msgid "Code Failed" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code_help +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code_help +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code_help +msgid "Code Help" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +msgid "Code Status" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses +msgid "" +"Comma-separated list of IP addresses and/or subnets (e.g. " +"192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e)." +" Requests from other addresses will be denied." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips +msgid "" +"Comma-separated list of trusted proxy IP addresses or CIDR ranges (e.g., " +"10.0.0.1,192.168.1.0/24). Only these proxies can set X-Forwarded-For " +"headers." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model,name:cetmix_tower_webhook.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "Content Type" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id +msgid "Country" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id +msgid "Country of the client that made the request." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_uid +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_uid +msgid "Created by" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_date +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_date +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_date +msgid "Created on" +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0 +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0 +msgid "" +"Dictionary containing the request payload (JSON for POST, params for GET)" +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0 +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0 +msgid "Dictionary of request headers" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "Disabled" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__display_name +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__display_name +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__display_name +msgid "Display Name" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__active +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "Enabled" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__endpoint +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__endpoint +msgid "Endpoint" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.constraint,message:cetmix_tower_webhook.constraint_cx_tower_webhook_endpoint_method_uniq +msgid "Endpoint and method must be unique!" +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0 +msgid "" +"Endpoint must start and end with a letter or digit, and may contain " +"underscores, dashes, and slashes in between" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +msgid "" +"Enter Python code here. Help about Python expression is available in the " +"help tab of this document" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "" +"Enter Python code here. Help about Python expression is available in the " +"help tab of this document." +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form +msgid "Error" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message +msgid "Error Message" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message +msgid "Error message in case of authentication or code failure." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_eval_mixin +msgid "Eval context/code helper for Cetmix Tower Webhook" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_authenticator_export_yaml +#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_export_yaml +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "Export YAML" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__failed +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__failed +msgid "Failed" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__form +msgid "Form URL-Encoded" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__full_url +msgid "Full URL of the webhook" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__full_url +msgid "Full Url" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__get +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__get +msgid "GET" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "Group By" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +msgid "HTTP 200" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +msgid "HTTP Status" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status +msgid "HTTP status code returned to the client." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers +msgid "Headers of the received HTTP request (JSON-encoded)." +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "Help" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__content_type +msgid "" +"How the payload is expected to be sent to this webhook: as JSON body or as " +"URL-encoded form data" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__id +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__id +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__id +msgid "ID" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address +msgid "IP Address" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address +msgid "IP address of the client that made the request." +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0 +msgid "Invalid allowed IP/CIDR entry: %s" +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0 +msgid "Invalid trusted proxy entry: %s" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__json +msgid "JSON" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration +msgid "Keep Webhook Logs for (days)" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_uid +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_date +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_date +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_date +msgid "Last Updated on" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__log_count +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__log_count +msgid "Log Count" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "Logs" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message +msgid "Message returned by the webhook code or authenticator (if any)." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__method +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "Method" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__name +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__name +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__name +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form +msgid "Name" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "Name/Reference" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_log_action +msgid "No webhook logs found" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__not_required +msgid "Not Required" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__post +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__post +msgid "POST" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__content_type +msgid "Payload Type" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload +msgid "Payload/body of the received HTTP request (JSON-encoded)." +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0 +msgid "Raw body of the request (bytes)" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__reference +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference +msgid "Reference" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form +msgid "Request Headers" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method +msgid "Request Method" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form +msgid "Request Payload" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form +msgid "Response Payload" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message +msgid "Result Message" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status +msgid "Result of authentication for this webhook call." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status +msgid "Result of webhook code execution." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__user_id +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id +msgid "Run as User" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_search +msgid "Search Webhook Authenticators" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "Search Webhooks" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__secret_ids +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__secret_ids +msgid "Secrets" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__user_id +msgid "" +"Select a user to run the webhook from behalf of. If not set, the webhook will run as the current user.\n" +"CAREFUL! You must realise and understand what you are doing including all the possible consequences when selecting a specific user" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id +msgid "Select an Authenticator used for this webhook" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__method +msgid "Select the HTTP method for this webhook" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method +msgid "Select the HTTP method for this webhook." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.res_config_settings_view_form +msgid "" +"Set the number of days to keep webhook logs. Old logs will be deleted " +"automatically." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__skipped +msgid "Skipped" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__success +#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__success +msgid "Success" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__code +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code +msgid "This field will be rendered using variables" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips +msgid "Trusted Proxy IPs" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search +msgid "User" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id +msgid "User as which the webhook code was executed (if set)." +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__variable_ids +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__variable_ids +msgid "Variables" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +msgid "Webhook" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_authenticator +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids +msgid "Webhook Authenticator" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids_count +msgid "Webhook Authenticator Count" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_authenticator_action +#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_authenticator +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form +msgid "Webhook Authenticators" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_log +msgid "Webhook Call Log" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_log +msgid "Webhook Calls" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status +msgid "Webhook Code Status" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids_count +msgid "Webhook Count" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_log_action +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search +msgid "Webhook Logs" +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0 +msgid "Webhook code execution error: %(error)s" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__endpoint +msgid "" +"Webhook endpoint. The complete URL will be " +"/cetmix_tower_webhooks/" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id +msgid "Webhook that received the call." +msgstr "" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py:0 +msgid "Webhook/Authenticator code error: result is not a dict" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_action +#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form +msgid "Webhooks" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "YAML" +msgstr "" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__yaml_code +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__yaml_code +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__yaml_code +msgid "Yaml Code" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +msgid "You must be a member of the \"YAML/Export\" group to export data as YAML" +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "You must be a member of the \"YAML/Export\" group to export data as YAML." +msgstr "" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +msgid "" +"e.g.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e" +msgstr "" diff --git a/addons/cetmix_tower_webhook/i18n/it.po b/addons/cetmix_tower_webhook/i18n/it.po new file mode 100644 index 0000000..3e21b16 --- /dev/null +++ b/addons/cetmix_tower_webhook/i18n/it.po @@ -0,0 +1,806 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: Stefano Consolaro \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Errore esecuzione codie webhook: %(error)s\n" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/constants.py:0 +#, python-format +msgid "" +"\n" +"

Help for Webhook Authenticator Python Code

\n" +"
\n" +"

\n" +" The Python code for the webhook authenticator must return the result variable, which is a dictionary.
\n" +" Allowed keys:\n" +"

    \n" +"
  • allowed (bool, required): Authentication result. True if allowed, False otherwise.
  • \n" +"
  • http_code (int, optional): HTTP status code to return if authentication fails (default is 403).
  • \n" +"
  • message (str, optional): Error message to show to the client.
  • \n" +"
\n" +" Examples:\n" +"
\n"
+"# Allow all requests\n"
+"result = {\"allowed\": True}\n"
+"\n"
+"# Deny with custom code and message\n"
+"result = {\"allowed\": False, \"http_code\": 401, \"message\": \"Unauthorized request\"}\n"
+"        
\n" +"

\n" +" Available variables:\n" +"
\n" +msgstr "" +"\n" +"

Aiuto per il codice Python di autenticazione webhook

\n" +"
\n" +"

\n" +" Il codice Python per l'autenticazione webhook deve restituire la variabile result, che è un dizionario.
\n" +" Chiavi consentite:\n" +"

    \n" +"
  • allowed (bool, richiesto): risultato autenticazione. True se abilitato, False altrimenti.
  • \n" +"
  • http_code (int, opzionale): codice stato HTTP da restituire se l'autenticazione fallisce (predefinito 403).
  • \n" +"# Nega con codice e messaggio personalizzati\n" +"# Nega con codice e messaggio personalizzati\n" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/constants.py:0 +#, python-format +msgid "" +"\n" +"

    Help for Webhook Python Code

    \n" +"
    \n" +"

    \n" +" The webhook Python code must set the result variable, which is a dictionary.
    \n" +" Allowed keys:\n" +"

      \n" +"
    • exit_code (int, optional, default=0): Exit code (0 means success, other values indicate failure).
    • \n" +"
    • message (str, optional): Message to return in the HTTP response and log.
    • \n" +"
    \n" +" Example:\n" +"
    \n"
    +"# Simple successful result\n"
    +"result = {\"exit_code\": 0, \"message\": \"Webhook processed successfully\"}\n"
    +"\n"
    +"# Failure example\n"
    +"result = {\"exit_code\": 1, \"message\": \"Something went wrong\"}\n"
    +"        
    \n" +"

    \n" +" Available variables:\n" +"
    \n" +msgstr "" +"\n" +"

    Aiuto per codice Python webhook

    \n" +"
    \n" +"

    \n" +" Il codice Python webhook deve impostare la variabile result, che è un dizionario.
    \n" +" Chiavi consentite:\n" +"

      \n" +"
    • exit_code (int, opzionale, predefinito=0): codice di uscita (0 significa successo, altri valori indicano fallimento).
    • \n" +"
    • message (str, opzionale): messaggio da restituire nella risposta HTTP e nel log.
    • \n" +"
    \n" +" Esempio:\n" +"
    \n"
    +"# Risultato successo semplice\n"
    +"result = {\"exit_code\": 0, \"message\": \"Webhook elaborato con successo\"}\n"
    +"\n"
    +"# Esempio di fallimento\n"
    +"result = {\"exit_code\": 1, \"message\": \"Qualcosa è andato storto\"}\n"
    +"        
    \n" +"

    \n" +" Variabili disponibili:\n" +"
    \n" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/constants.py:0 +#, python-format +msgid "" +"# Please refer to the 'Help' tab and documentation for more information.\n" +"#\n" +"# You can return authenticator result in the 'result' variable which is a dictionary:\n" +"# result = {\"allowed\": , \"http_code\": , \"message\": }\n" +"# default value is {\"allowed\": False}\n" +msgstr "" +"# Fare riferimento alla scheda 'Help' e alla documentazione per informazioni aggiuntive.\n" +"#\n" +"# Si può restituire il risultato dell'autenticazione nella variabile 'result' che è un dizionario:\n" +"# result = {\"allowed\": , \"http_code\": , \"message\": }\n" +"# il valore predefinito è {\"allowed\": False}\n" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/constants.py:0 +#, python-format +msgid "" +"# Please refer to the 'Help' tab and documentation for more information.\n" +"#\n" +"# You can return webhook result in the 'result' variable which is a dictionary:\n" +"# result = {\"exit_code\": , \"message\": , \"message\": /cetmix_tower_webhooks/" +msgstr "Endpoint del webhook. L'URL completo sarà /cetmix_tower_webhooks/" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id +msgid "Webhook that received the call." +msgstr "Webhook che ha ricevuto la chiamata" + +#. module: cetmix_tower_webhook +#. odoo-python +#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py:0 +#, python-format +msgid "Webhook/Authenticator code error: result is not a dict" +msgstr "Errore codice webhook/autenticatore: il risultato non è un dizionario" + +#. module: cetmix_tower_webhook +#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_action +#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form +msgid "Webhooks" +msgstr "Webhook" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "YAML" +msgstr "YAML" + +#. module: cetmix_tower_webhook +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__yaml_code +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__yaml_code +#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__yaml_code +msgid "Yaml Code" +msgstr "Codice YAML" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +msgid "You must be a member of the \"YAML/Export\" group to export data as YAML" +msgstr "Bisogna essere membro del gruppo \"YAML/Export\" per esportare i dati come YAML" + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form +msgid "You must be a member of the \"YAML/Export\" group to export data as YAML." +msgstr "Bisogna essere membro del gruppo \"YAML/Export\" per esportare i dati come YAML." + +#. module: cetmix_tower_webhook +#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form +msgid "e.g.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e" +msgstr "es.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e" + +#~ msgid "Last Modified on" +#~ msgstr "Ultima modifica il" + +#~ msgid "" +#~ "Set the number of days to keep webhook logs. Old logs will be deleted " +#~ "automatically.\n" +#~ "
    " +#~ msgstr "" +#~ "Imposta il numero di giorni per cui conservare i registri dei webhook. I " +#~ "vecchi registri verranno eliminati " +#~ "automaticamente.
    " diff --git a/addons/cetmix_tower_webhook/models/__init__.py b/addons/cetmix_tower_webhook/models/__init__.py new file mode 100644 index 0000000..5d94837 --- /dev/null +++ b/addons/cetmix_tower_webhook/models/__init__.py @@ -0,0 +1,9 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import cx_tower_webhook_eval_mixin +from . import cx_tower_webhook_authenticator +from . import cx_tower_webhook_log +from . import cx_tower_webhook +from . import cx_tower_variable +from . import res_config_settings diff --git a/addons/cetmix_tower_webhook/models/constants.py b/addons/cetmix_tower_webhook/models/constants.py new file mode 100644 index 0000000..b108632 --- /dev/null +++ b/addons/cetmix_tower_webhook/models/constants.py @@ -0,0 +1,70 @@ +# flake8: noqa: E501 +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +# Default Python code used in Webhook Authenticator +DEFAULT_WEBHOOK_AUTHENTICATOR_CODE = """# Please refer to the 'Help' tab and documentation for more information. +# +# You can return authenticator result in the 'result' variable which is a dictionary: +# result = {"allowed": , "http_code": , "message": } +# default value is {"allowed": False} +""" + +# Default Python code help used in Webhook Authenticator +DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP = """ +

    Help for Webhook Authenticator Python Code

    +
    +

    + The Python code for the webhook authenticator must return the result variable, which is a dictionary.
    + Allowed keys: +

      +
    • allowed (bool, required): Authentication result. True if allowed, False otherwise.
    • +
    • http_code (int, optional): HTTP status code to return if authentication fails (default is 403).
    • +
    • message (str, optional): Error message to show to the client.
    • +
    + Examples: +
    +# Allow all requests
    +result = {"allowed": True}
    +
    +# Deny with custom code and message
    +result = {"allowed": False, "http_code": 401, "message": "Unauthorized request"}
    +        
    +

    + Available variables: +
    +""" + + +# Default Python code used in Webhook +DEFAULT_WEBHOOK_CODE = """# Please refer to the 'Help' tab and documentation for more information. +# +# You can return webhook result in the 'result' variable which is a dictionary: +# result = {"exit_code": , "message": } +# default value is {"exit_code": 0, "message": None} +""" + +# Default Python code help used in Webhook +DEFAULT_WEBHOOK_CODE_HELP = """ +

    Help for Webhook Python Code

    +
    +

    + The webhook Python code must set the result variable, which is a dictionary.
    + Allowed keys: +

      +
    • exit_code (int, optional, default=0): Exit code (0 means success, other values indicate failure).
    • +
    • message (str, optional): Message to return in the HTTP response and log.
    • +
    + Example: +
    +# Simple successful result
    +result = {"exit_code": 0, "message": "Webhook processed successfully"}
    +
    +# Failure example
    +result = {"exit_code": 1, "message": "Something went wrong"}
    +        
    +

    + Available variables: +
    +""" diff --git a/addons/cetmix_tower_webhook/models/cx_tower_variable.py b/addons/cetmix_tower_webhook/models/cx_tower_variable.py new file mode 100644 index 0000000..bd0965c --- /dev/null +++ b/addons/cetmix_tower_webhook/models/cx_tower_variable.py @@ -0,0 +1,85 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class CxTowerVariable(models.Model): + _inherit = "cx.tower.variable" + + # --- Link to records where the variable is used + webhook_ids = fields.Many2many( + comodel_name="cx.tower.webhook", + relation="cx_tower_webhook_variable_rel", + column1="variable_id", + column2="webhook_id", + copy=False, + ) + webhook_ids_count = fields.Integer( + string="Webhook Count", compute="_compute_webhook_ids_count" + ) + webhook_authenticator_ids = fields.Many2many( + comodel_name="cx.tower.webhook.authenticator", + relation="cx_tower_webhook_authenticator_variable_rel", + column1="variable_id", + column2="webhook_authenticator_id", + copy=False, + ) + webhook_authenticator_ids_count = fields.Integer( + string="Webhook Authenticator Count", compute="_compute_webhook_ids_count" + ) + + def _compute_webhook_ids_count(self): + """ + Count number of webhooks and webhook authenticators for the variable + """ + for rec in self: + rec.update( + { + "webhook_ids_count": len(rec.webhook_ids), + "webhook_authenticator_ids_count": len( + rec.webhook_authenticator_ids + ), + } + ) + + def action_open_webhooks(self): + """Open the webhooks where the variable is used""" + + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_webhook.cx_tower_webhook_action" + ) + action.update( + { + "domain": [("variable_ids", "in", self.ids)], + } + ) + return action + + def action_open_webhook_authenticators(self): + """Open the webhook authenticators where the variable is used""" + + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_webhook.cx_tower_webhook_authenticator_action" + ) + action.update( + { + "domain": [("variable_ids", "in", self.ids)], + } + ) + return action + + def _get_propagation_field_mapping(self): + """ + Override to add webhook and webhook authenticator + to the propagation field mapping. + """ + res = super()._get_propagation_field_mapping() + res.update( + { + "cx.tower.webhook": ["code"], + "cx.tower.webhook.authenticator": ["code"], + } + ) + return res diff --git a/addons/cetmix_tower_webhook/models/cx_tower_webhook.py b/addons/cetmix_tower_webhook/models/cx_tower_webhook.py new file mode 100644 index 0000000..aca4159 --- /dev/null +++ b/addons/cetmix_tower_webhook/models/cx_tower_webhook.py @@ -0,0 +1,221 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re + +from odoo import SUPERUSER_ID, _, api, fields, models +from odoo.exceptions import ValidationError + +from .constants import DEFAULT_WEBHOOK_CODE, DEFAULT_WEBHOOK_CODE_HELP + + +class CxTowerWebhook(models.Model): + """Webhook""" + + _name = "cx.tower.webhook" + _inherit = [ + "cx.tower.webhook.eval.mixin", + ] + _description = "Webhook" + + active = fields.Boolean( + default=True, + string="Enabled", + ) + authenticator_id = fields.Many2one( + comodel_name="cx.tower.webhook.authenticator", + required=True, + help="Select an Authenticator used for this webhook", + ) + endpoint = fields.Char( + required=True, + copy=False, + help="Webhook endpoint. The complete URL will be " + "/cetmix_tower_webhooks/", + ) + full_url = fields.Char( + compute="_compute_full_url", + help="Full URL of the webhook", + ) + method = fields.Selection( + [ + ("post", "POST"), + ("get", "GET"), + ], + default="post", + required=True, + help="Select the HTTP method for this webhook", + ) + content_type = fields.Selection( + [ + ("json", "JSON"), + ("form", "Form URL-Encoded"), + ], + string="Payload Type", + default="json", + required=True, + help="How the payload is expected to be sent to this webhook: " + "as JSON body or as URL-encoded form data", + ) + user_id = fields.Many2one( + comodel_name="res.users", + string="Run as User", + help="Select a user to run the webhook from behalf of. If not set, " + "the webhook will run as the current user.\n" + "CAREFUL! You must realise and understand what you are doing including " + "all the possible consequences when selecting a specific user", + default=SUPERUSER_ID, + required=True, + copy=False, + ) + log_count = fields.Integer( + compute="_compute_log_count", + ) + variable_ids = fields.Many2many( + comodel_name="cx.tower.variable", + relation="cx_tower_webhook_variable_rel", + column1="webhook_id", + column2="variable_id", + ) + + _sql_constraints = [ + ( + "endpoint_method_uniq", + "unique(endpoint, method)", + "Endpoint and method must be unique!", + ), + ] + + def _compute_log_count(self): + """Compute log count.""" + data = { + webhook.id: count + for webhook, count in self.env["cx.tower.webhook.log"]._read_group( + domain=[("webhook_id", "in", self.ids)], + groupby=["webhook_id"], + aggregates=["__count"], + ) + } + for rec in self: + rec.log_count = data.get(rec.id, 0) + + @api.depends("endpoint") + def _compute_full_url(self): + """Compute full URL.""" + base_url = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("web.base.url", "") + .rstrip("/") + ) + for rec in self: + rec.full_url = f"{base_url}/cetmix_tower_webhooks/{rec.endpoint}" + + @api.constrains("endpoint") + def _check_endpoint_format(self): + """Validate endpoint format.""" + pattern = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_/-]*[A-Za-z0-9])?$") + for rec in self: + if rec.endpoint and not pattern.fullmatch(rec.endpoint): + raise ValidationError( + _( + "Endpoint must start and end with a letter or digit, " + "and may contain underscores, dashes, and slashes in between" + ) + ) + + def _default_eval_code(self): + """ + Returns the default code for the webhook. + """ + return _(DEFAULT_WEBHOOK_CODE) + + def _get_default_python_eval_code_help(self): + """ + Returns the default code help for the webhook. + """ + return _(DEFAULT_WEBHOOK_CODE_HELP) + + def _get_python_eval_odoo_objects(self, **kwargs): + """ + Override to add custom Odoo objects. + """ + res = { + "headers": { + "import": kwargs.get("headers"), + "help": _("Dictionary of request headers"), + }, + "payload": { + "import": kwargs.get("payload"), + "help": _( + "Dictionary containing the request payload " + "(JSON for POST, params for GET)" + ), + }, + } + res.update(super()._get_python_eval_odoo_objects(**kwargs)) + return res + + def _get_fields_for_yaml(self): + """Override to add fields to YAML export.""" + res = super()._get_fields_for_yaml() + res += [ + "name", + "active", + "authenticator_id", + "endpoint", + "method", + "code", + "content_type", + "variable_ids", + "secret_ids", + ] + return res + + def execute(self, payload=None, raise_on_error=True, **kwargs): + """ + Run the webhook code and return a validated result. + Handles errors and checks result format. + + Args: + payload (dict): The webhook payload. If not provided, + the payload will be empty. + raise_on_error (bool): Raise ValidationError on error if True. + **kwargs: Additional keyword arguments. + + Returns: + dict: { + 'exit_code': , + 'message': + } + """ + self.ensure_one() + self_with_user = self.with_user(self.user_id) + payload = payload or {} + + try: + result = self_with_user._run_webhook_eval_code( + self_with_user.code, + context_extra={"payload": payload, "headers": kwargs.get("headers")}, + default_result={"exit_code": 0, "message": None}, + ) + except Exception as e: + if raise_on_error: + raise ValidationError( + _("Webhook code execution error: %(error)s", error=e) + ) from e + result = { + "exit_code": 1, + "message": str(e), + } + + return result + + def action_view_logs(self): + """Open logs related to this webhook.""" + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_webhook.cx_tower_webhook_log_action" + ) + action["domain"] = [("webhook_id", "=", self.id)] + return action diff --git a/addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py b/addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py new file mode 100644 index 0000000..6cd6ec8 --- /dev/null +++ b/addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py @@ -0,0 +1,383 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import ipaddress +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.http import request + +from .constants import ( + DEFAULT_WEBHOOK_AUTHENTICATOR_CODE, + DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP, +) + +_logger = logging.getLogger(__name__) + + +class CxTowerWebhookAuthenticator(models.Model): + """Webhook Authenticator""" + + _name = "cx.tower.webhook.authenticator" + _inherit = [ + "cx.tower.webhook.eval.mixin", + ] + _description = "Webhook Authenticator" + + log_count = fields.Integer( + compute="_compute_log_count", + ) + allowed_ip_addresses = fields.Text( + string="Allowed IPs", + help="Comma-separated list of IP addresses and/or subnets " + "(e.g. 192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e). " # noqa: E501 + "Requests from other addresses will be denied.", + ) + trusted_proxy_ips = fields.Text( + string="Trusted Proxy IPs", + help="Comma-separated list of trusted proxy IP addresses or CIDR ranges " + "(e.g., 10.0.0.1,192.168.1.0/24). " + "Only these proxies can set X-Forwarded-For headers.", + ) + variable_ids = fields.Many2many( + comodel_name="cx.tower.variable", + relation="cx_tower_webhook_authenticator_variable_rel", + column1="webhook_authenticator_id", + column2="variable_id", + ) + + @api.constrains("trusted_proxy_ips") + def _check_trusted_proxy_ips(self): + """ + Validate 'trusted_proxy_ips' entries. Accepts single IPs and CIDR ranges + (IPv4/IPv6). Empty value is allowed. + """ + for rec in self: + invalid = self._validate_ip_token((rec.trusted_proxy_ips or "").strip()) + if invalid: + raise ValidationError(_("Invalid trusted proxy entry: %s") % invalid) + + @api.constrains("allowed_ip_addresses") + def _check_allowed_ip_addresses(self): + """ + Validate 'allowed_ip_addresses' entries. Accepts single IPs and CIDR + ranges (IPv4/IPv6). Empty value is allowed (means allow all). + """ + for rec in self: + invalid = self._validate_ip_token((rec.allowed_ip_addresses or "").strip()) + if invalid: + raise ValidationError(_("Invalid allowed IP/CIDR entry: %s") % invalid) + + def _compute_log_count(self): + """Compute log count.""" + data = { + webhook.id: count + for webhook, count in self.env["cx.tower.webhook.log"]._read_group( + domain=[("authenticator_id", "in", self.ids)], + groupby=["authenticator_id"], + aggregates=["__count"], + ) + } + for rec in self: + rec.log_count = data.get(rec.id, 0) + + def _default_eval_code(self): + """ + Return the default Python code for the webhook authenticator. + + Returns: + str: Default authenticator code. + """ + return _(DEFAULT_WEBHOOK_AUTHENTICATOR_CODE) + + def _get_default_python_eval_code_help(self): + """ + Return the default help text for the authenticator code. + + Returns: + str: Code help description. + """ + return _(DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP) + + def _get_python_eval_odoo_objects(self, **kwargs): + """ + Extend the Python evaluation context with custom Odoo objects. + + Args: + **kwargs: Extra context values, e.g.: + - "headers": request headers (dict) + - "raw_data": request body (bytes) + - "payload": parsed request payload (dict) + + Returns: + dict: Mapping of variables available in evaluation context. + """ + res = { + "headers": { + "import": kwargs.get("headers"), + "help": _("Dictionary of request headers"), + }, + "raw_data": { + "import": kwargs.get("raw_data"), + "help": _("Raw body of the request (bytes)"), + }, + "payload": { + "import": kwargs.get("payload"), + "help": _( + "Dictionary containing the request payload " + "(JSON for POST, params for GET)" + ), + }, + } + res.update(super()._get_python_eval_odoo_objects(**kwargs)) + return res + + def _get_fields_for_yaml(self): + """ + Extend fields available for YAML export. + + Returns: + list[str]: List of field names. + """ + res = super()._get_fields_for_yaml() + res += [ + "name", + "code", + "allowed_ip_addresses", + "trusted_proxy_ips", + "variable_ids", + "secret_ids", + ] + return res + + def authenticate(self, raise_on_error=True, **kwargs): + """ + Run the authenticator code and return result. + + Args: + raise_on_error (bool): Raise ValidationError on error if True. + kwargs: Additional variables passed to the code context, e.g.: + - "headers": request headers (dict) + - "raw_data": request body (bytes) + - "payload": parsed request payload (dict) + + Returns: + dict: { + "allowed": , + "http_code": , + "message": , + } + """ + self.ensure_one() + try: + result = self._run_webhook_eval_code( + self.code, + context_extra={ + "headers": kwargs.get("headers"), + "raw_data": kwargs.get("raw_data"), + "payload": kwargs.get("payload"), + }, + default_result={"allowed": False}, + ) + except Exception as e: + if raise_on_error: + raise ValidationError(_("Authentication code error: %s") % e) from e + result = { + "allowed": False, + "http_code": 500, + "message": str(e), + } + + return result + + def action_view_logs(self): + """ + Open the action displaying logs related to this authenticator. + + Returns: + dict: Action dictionary for `ir.actions.act_window`. + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_webhook.cx_tower_webhook_log_action" + ) + action["domain"] = [("authenticator_id", "=", self.id)] + return action + + def is_ip_allowed(self, remote_addr): + """ + Proxy-aware allowlist check. + + Steps: + 1) Compute the effective client IP. + 2) If 'allowed_ip_addresses' is empty: allow everyone (backward compatible). + 3) Otherwise, allow only if the client IP belongs to any network in + 'allowed_ip_addresses'. + + Args: + remote_addr (str): Immediate TCP peer IP (controller-provided). + + Returns: + bool: True if client IP is allowed, False otherwise. + """ + self.ensure_one() + + client_ip = self._effective_client_ip(remote_addr) + if not client_ip: + return False + + spec = (self.allowed_ip_addresses or "").strip() + if not spec: + return True + + allowed_nets = self._parse_ip_list_to_networks(spec) + if not allowed_nets: + # Misconfigured allowlist: fail closed + return False + + return any(client_ip in net for net in allowed_nets) + + def _effective_client_ip(self, remote_addr): + """ + Compute the effective client IP for the current HTTP request. + + Security model: + - The immediate TCP peer is 'remote_addr' + (or request.httprequest.remote_addr). + - X-Forwarded-For / X-Real-IP are honored ONLY if the immediate peer + is within 'trusted_proxy_ips' (single IPs or CIDR ranges). + - If not behind a trusted proxy, headers are ignored to prevent spoofing. + + Args: + remote_addr (str): Immediate TCP peer IP passed by the controller. + + Returns: + ipaddress.IPv4Address|ipaddress.IPv6Address|None: + Effective client IP or None. + """ + immediate_peer = remote_addr or getattr( + getattr(request, "httprequest", None), "remote_addr", None + ) + if not immediate_peer: + return None + + try: + immediate_ip = ipaddress.ip_address(immediate_peer) + except (ValueError, TypeError): + return None + + client_ip = immediate_ip # default to immediate peer + trusted_nets = self._parse_ip_list_to_networks( + (self.trusted_proxy_ips or "").strip() + ) + headers = getattr(getattr(request, "httprequest", None), "headers", {}) or {} + is_trusted_proxy = ( + any(immediate_ip in net for net in trusted_nets) if trusted_nets else False + ) + + if is_trusted_proxy: + candidate = self._extract_ip_from_header(headers.get("X-Forwarded-For")) + if not candidate: + candidate = self._extract_ip_from_header(headers.get("X-Real-IP")) + if candidate: + try: + client_ip = ipaddress.ip_address(candidate) + except ValueError: + # Fall back to immediate peer if candidate is invalid. + _logger.warning("Invalid IP/CIDR entry") + + return client_ip + + def _extract_ip_from_header(self, header_value): + """ + Extract the first valid IP from a proxy-provided header. + + Behavior: + - For X-Forwarded-For, the left-most entry is + considered the original client IP. + - For X-Real-IP, the value itself is considered. + - Any non-IP tokens are skipped. + + Args: + header_value (str): Raw header value (may contain commas for XFF). + + Returns: + str|None: Compressed IPv4/IPv6 string, or None if nothing valid is found. + """ + if not header_value: + return None + + for token in header_value.split(","): + ip_str = token.strip() + if not ip_str: + continue + try: + return ipaddress.ip_address(ip_str).compressed + except ValueError: + continue + return None + + @staticmethod + def _parse_ip_list_to_networks(spec): + """ + Convert a CSV of IPs/CIDRs into a list of ip_network objects. + Single IPs are normalized to /32 (IPv4) or /128 (IPv6). + + Args: + spec (str): CSV of IPs/CIDRs. + + Returns: + list[ipaddress.IPv4Network|ipaddress.IPv6Network] + """ + nets = [] + if not spec: + return nets + for part in spec.split(","): + s = (part or "").strip() + if not s: + continue + try: + nets.append(ipaddress.ip_network(s, strict=False)) + continue + except ValueError: + _logger.warning( + "Invalid IP/CIDR entry encountered in " + "trusted_proxy_ips configuration." + ) + try: + ip = ipaddress.ip_address(s) + nets.append( + ipaddress.ip_network( + ip.exploded + ("/32" if ip.version == 4 else "/128") + ) + ) + except ValueError: + # Ignore invalid entries silently; validation is handled by constraints. + continue + return nets + + def _validate_ip_token(self, spec): + """ + Return the first invalid token from a CSV of IPs/CIDRs, + or None if all valid. + Accepts single IPs and CIDR ranges (IPv4/IPv6). + Empty/whitespace tokens are ignored. + """ + if not spec: + return None + for part in spec.split(","): + s = (part or "").strip() + if not s: + continue + try: + ipaddress.ip_network(s, strict=False) + continue + except ValueError: + _logger.warning("Invalid IP/CIDR entry encountered") + pass + try: + ipaddress.ip_address(s) + except ValueError: + return s + return None diff --git a/addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py b/addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py new file mode 100644 index 0000000..0047e41 --- /dev/null +++ b/addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py @@ -0,0 +1,201 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval + + +class CxTowerWebhookEvalMixin(models.AbstractModel): + _name = "cx.tower.webhook.eval.mixin" + _inherit = [ + "cx.tower.template.mixin", + "cx.tower.key.mixin", + "cx.tower.yaml.mixin", + "cx.tower.reference.mixin", + ] + _description = "Eval context/code helper for Cetmix Tower Webhook" + + code_help = fields.Html( + compute="_compute_code_help", + default=lambda self: self._default_eval_code_help(), + compute_sudo=True, + ) + code = fields.Text( + default=lambda self: self._default_eval_code(), + required=True, + ) + + @classmethod + def _get_depends_fields(cls): + """Add code to the depends fields.""" + return ["code"] + + def _compute_code_help(self): + """ + Compute code help + """ + self.code_help = self._default_eval_code_help() + + def _default_eval_code_help(self): + """ + Return the default code help text for webhook or authenticator. + + We use default because the computation method for this field + would not be triggered before this record is saved. And we need + to show the value instantly. + + Returns: + str: HTML-formatted help string containing available objects and libraries. + """ + available_libraries = self._get_python_eval_odoo_objects() + available_libraries.update(self._get_python_eval_libraries()) + help_text_fragments = [] + for key, value in available_libraries.items(): + if key == "server": + # Server is not available in the webhook/authenticator eval context + continue + help_text_fragments.append(f"
  • {key}: {value['help']}
  • ") + + help_text = "
      " + "".join(help_text_fragments) + "
    " + return f"{self._get_default_python_eval_code_help()}{help_text}" + + def _get_python_eval_odoo_objects(self, **kwargs): + """ + Return Odoo objects available in the eval context. + + Args: + **kwargs: Optional context values. + + Returns: + dict: Mapping of object names to their import values and help. + """ + return self.env["cx.tower.command"]._get_python_command_odoo_objects() + + def _get_python_eval_libraries(self): + """ + Return Python libraries available in the eval context. + + Returns: + dict: Mapping of library names to their import values and help. + """ + return self.env["cx.tower.command"]._get_python_command_libraries() + + def _get_default_python_eval_code_help(self): + """ + Return the default help text for eval code. + + Returns: + str: Help text. + """ + return "" + + def _default_eval_code(self): + """ + Return the default code for webhook or authenticator. + + Returns: + str: Default Python code. + """ + return "" + + def _prepare_webhook_eval_context(self, context_extra=None, default_result=None): + """ + Build the evaluation context for webhook or authenticator + safe_eval. + + Args: + context_extra (dict): Additional context variables + (payload, headers, etc). + default_result (dict): Default value for the 'result' variable. + + Returns: + dict: Prepared eval context. + """ + context_extra = context_extra or {} + # Get the Odoo objects first + imports = self._get_python_eval_odoo_objects(**context_extra) + + # Update with the libraries + imports.update(self._get_python_eval_libraries()) + eval_context = {key: value["import"] for key, value in imports.items()} + + # Remove server from eval context + eval_context.pop("server", None) + + # Set default result + default_result = default_result or {} + eval_context["result"] = default_result.copy() + + return eval_context + + def _run_webhook_eval_code(self, code, **kwargs): + """ + Helper to execute user code safely. Returns the 'result' variable from context. + + Args: + code (str): User code to run + kwargs: + key (dict): Extra keys for secret parser + context_extra (dict): Extra context variables (payload, headers, etc) + default_result (dict): Default value for the 'result' variable + + Returns: + dict: The 'result' variable from context + """ + eval_context = self._prepare_webhook_eval_context(**kwargs) + + if not code: + # if code is empty, return the default result + return eval_context["result"] + + # prepare the code for evaluation + code_and_secrets = self.env["cx.tower.key"]._parse_code_and_return_key_values( + code, pythonic_mode=True, **kwargs.get("key", {}) + ) + secrets = code_and_secrets.get("key_values") + webhook_code = code_and_secrets["code"] + + code = self.env["cx.tower.key"]._parse_code( + webhook_code, pythonic_mode=True, **kwargs.get("key", {}) + ) + + # execute user code + safe_eval( + code, + eval_context, + mode="exec", + nocopy=True, + ) + result = eval_context["result"] + return self._parse_eval_code_result(result, secrets=secrets, **kwargs) + + def _parse_eval_code_result(self, result, secrets=None, **kwargs): + """ + Post-processes the result returned from webhook/authenticator eval code. + + If 'secrets' are provided, all occurrences of secret values in the + 'message' field of result will be replaced with a spoiler string to + prevent sensitive information leakage. + + Args: + result (dict): The dict returned from the executed eval code, + expected to have at least a 'message' key. + secrets (dict, optional): A mapping of secret key-value + pairs used for replacement in 'message'. + + Returns: + dict: The processed result with secrets masked in the 'message' + field, if applicable. + """ + if not isinstance(result, dict): + raise ValidationError( + _("Webhook/Authenticator code error: result is not a dict") + ) + + if secrets and isinstance(result.get("message"), str): + result["message"] = self.env["cx.tower.key"]._replace_with_spoiler( + result["message"], secrets + ) + + return result diff --git a/addons/cetmix_tower_webhook/models/cx_tower_webhook_log.py b/addons/cetmix_tower_webhook/models/cx_tower_webhook_log.py new file mode 100644 index 0000000..c602012 --- /dev/null +++ b/addons/cetmix_tower_webhook/models/cx_tower_webhook_log.py @@ -0,0 +1,197 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import api, fields, models +from odoo.http import request + + +class CxTowerWebhookLog(models.Model): + """Webhook Call Log""" + + _name = "cx.tower.webhook.log" + _description = "Webhook Call Log" + _order = "create_date desc" + _rec_name = "display_name" + + webhook_id = fields.Many2one( + comodel_name="cx.tower.webhook", + ondelete="cascade", + index=True, + help="Webhook that received the call.", + ) + endpoint = fields.Char( + readonly=True, + ) + authenticator_id = fields.Many2one( + comodel_name="cx.tower.webhook.authenticator", + readonly=True, + ) + request_method = fields.Selection( + [ + ("post", "POST"), + ("get", "GET"), + ], + default="post", + required=True, + help="Select the HTTP method for this webhook.", + ) + request_headers = fields.Text( + help="Headers of the received HTTP request (JSON-encoded).", + ) + request_payload = fields.Text( + help="Payload/body of the received HTTP request (JSON-encoded).", + ) + authentication_status = fields.Selection( + [ + ("success", "Success"), + ("failed", "Failed"), + ("not_required", "Not Required"), + ], + required=True, + default="failed", + help="Result of authentication for this webhook call.", + ) + code_status = fields.Selection( + [ + ("success", "Success"), + ("failed", "Failed"), + ("skipped", "Skipped"), + ], + string="Webhook Code Status", + required=True, + default="skipped", + help="Result of webhook code execution.", + ) + http_status = fields.Integer( + string="HTTP Status", + help="HTTP status code returned to the client.", + ) + result_message = fields.Text( + help="Message returned by the webhook code or authenticator (if any).", + ) + error_message = fields.Text( + help="Error message in case of authentication or code failure.", + ) + user_id = fields.Many2one( + comodel_name="res.users", + string="Run as User", + help="User as which the webhook code was executed (if set).", + ) + ip_address = fields.Char( + string="IP Address", + help="IP address of the client that made the request.", + ) + country_id = fields.Many2one( + comodel_name="res.country", + help="Country of the client that made the request.", + ) + display_name = fields.Char( + compute="_compute_display_name", + store=True, + readonly=True, + ) + + @api.depends("webhook_id", "endpoint", "http_status") + def _compute_display_name(self): + """Compute display name.""" + for rec in self: + rec.display_name = ( + f"{rec.webhook_id.display_name or ''} ({rec.endpoint}) " + f"[{rec.http_status or ''}]" + ) + + @api.model + def _get_country_id(self): + """ + Return the country ID of the client based on geoip information. + + Returns: + int | bool: Country ID if found, otherwise False. + """ + country_code = None + if request and hasattr(request, "geoip") and request.geoip: + country_code = request.geoip.get("country_code") + if country_code: + country = ( + self.env["res.country"] + .sudo() + .search([("code", "=", country_code)], limit=1) + ) + if country: + return country.id + return False + + @api.model + def _get_ip_address(self): + """ + Return the IP address of the client making the request. + + Returns: + str | None: IP address string, or None if unavailable. + """ + if not request: + return None + # Check for forwarded IP (common proxy headers) + forwarded_for = request.httprequest.headers.get("X-Forwarded-For") + if forwarded_for: + # Return the first IP in the chain + return forwarded_for.split(",")[0].strip() + return request.httprequest.remote_addr + + @api.model + def create_from_call(self, **kwargs): + """ + Create a log entry from webhook call parameters. + + Args: + **kwargs: Values passed to `_prepare_values`. + + Returns: + CxTowerWebhookLog: Newly created log record. + """ + values = self._prepare_values(**kwargs) + return self.create(values) + + @api.model + def _prepare_values(self, webhook=None, **kwargs): + """ + Prepare values for creating a webhook log record. + + Args: + webhook (RecordSet, optional): Webhook record. + **kwargs: Additional fields such as endpoint, request_method, etc. + + Returns: + dict: Prepared values for log creation. + """ + vals = { + "webhook_id": webhook.id if webhook else None, + "endpoint": webhook.endpoint if webhook else kwargs.get("endpoint"), + "authenticator_id": webhook.authenticator_id.id if webhook else None, + "request_method": webhook.method + if webhook + else kwargs.get("request_method"), + "user_id": webhook.user_id.id if webhook else None, + "ip_address": self._get_ip_address(), + "country_id": self._get_country_id(), + **kwargs, + } + return vals + + @api.autovacuum + def _gc_delete_old_logs(self): + """ + Remove old webhook log records beyond configured retention period. + + This method is automatically triggered by Odoo's autovacuum. + """ + days = int( + self.env["ir.config_parameter"] + .sudo() + .get_param("cetmix_tower_webhook.webhook_log_duration", 30) + ) + cutoff = fields.Datetime.now() - timedelta(days=days) + logs_to_delete = self.sudo().search([("create_date", "<", cutoff)]) + logs_to_delete.unlink() diff --git a/addons/cetmix_tower_webhook/models/res_config_settings.py b/addons/cetmix_tower_webhook/models/res_config_settings.py new file mode 100644 index 0000000..ef9c4c4 --- /dev/null +++ b/addons/cetmix_tower_webhook/models/res_config_settings.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + cetmix_tower_webhook_log_duration = fields.Integer( + string="Keep Webhook Logs for (days)", + help="Set the number of days to keep webhook logs. " + "Old logs will be deleted automatically.", + default=30, + config_parameter="cetmix_tower_webhook.webhook_log_duration", + ) diff --git a/addons/cetmix_tower_webhook/pyproject.toml b/addons/cetmix_tower_webhook/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/addons/cetmix_tower_webhook/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/addons/cetmix_tower_webhook/readme/CONFIGURE.md b/addons/cetmix_tower_webhook/readme/CONFIGURE.md new file mode 100644 index 0000000..a2fd8ce --- /dev/null +++ b/addons/cetmix_tower_webhook/readme/CONFIGURE.md @@ -0,0 +1,58 @@ +## Configure an Authenticator + +**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to configure authenticators.** + +- Go to "Cetmix Tower > Settings > Automation > Webhook Authenticators" and click "New". + +**Complete the following fields:** + +- Name. Authenticator name +- Reference. Unique reference. Leave this field blank to auto generate it +- Code. Code that is used to authenticate the request. You can use all Cetmix Tower - Python command variables except for the server​ plus the following webhook specific ones: +- headers: dictionary that contains the request headers +- raw_data: string with the raw HTTP request body +- payload: dictionary that contains the JSON payload or the GET parameters of the request + +**The code returns the result​ variable in the following format:** + +```python +result = {"allowed": , "http_code": , "message": } +``` + +eg: + +```python +result = {"allowed": True} +result = {"allowed": False, "http_code": 403, "message": "Sorry..."} +``` + +## Configure a Webhook + +**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to configure webhooks.** + +- Go to "Cetmix Tower > Settings > Automation > Webhooks" and click "New". + +**Complete the following fields:** + +- Enabled. Uncheck this field to disable the webhook without deleting it +- Name. Authenticator name +- Reference. Unique reference. Leave this field blank to auto generate it +- Authenticator. Select an Authenticator used for this webhook +- Endpoint. Webhook endpoint. The complete webhook URL will be /cetmix_tower_webhooks/​ +- Run as User. Select a user to run the webhook on behalf of. CAREFUL! You must realize and understand what you are doing, including all the possible consequences when selecting a specific user. +- Code. Code that processes the request. You can use all Cetmix Tower Python command variables (except for the server) plus the following webhook-specific one: + - headers: dictionary that contains the request headers + - payload: dictionary that contains the JSON payload or the GET parameters of the request + +Webhook code returns a result using the Cetmix Tower Python command pattern: + +```python +result = {"exit_code": , "message": } +``` + +**To configure the time for which the webhook call logs are stored:** + +- Go to "Cetmix Tower > Settings > General Settings" +- Put a number of days into the "Keep Webhook Logs for (days)" field. Default value is 30. + +Please refer to the [official documentation](https://tower.cetmix.com) for detailed configuration instructions. diff --git a/addons/cetmix_tower_webhook/readme/CONTEXT.md b/addons/cetmix_tower_webhook/readme/CONTEXT.md new file mode 100644 index 0000000..21db762 --- /dev/null +++ b/addons/cetmix_tower_webhook/readme/CONTEXT.md @@ -0,0 +1,2 @@ +Although Odoo has native support of webhooks staring 17.0, they still have some limitations. +Another option is the OCA 'endpoint' module which although is more flexible still makes it usable with Cetmix Tower more complicated. diff --git a/addons/cetmix_tower_webhook/readme/DESCRIPTION.md b/addons/cetmix_tower_webhook/readme/DESCRIPTION.md new file mode 100644 index 0000000..f106be8 --- /dev/null +++ b/addons/cetmix_tower_webhook/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module implements incoming webhooks for [Cetmix Tower](https://tower.cetmix.com). Webhooks are authorised using customisable authenticators which can be pre-configured and reused across multiple webhooks. Webhooks and authenticators can be exported and imported using YAML format, which makes them easily sharable. + +This module is a part of Cetmix Tower, however it can be used to manage any other odoo applications. + +Please refer to the [official documentation](https://tower.cetmix.com) for detailed information. diff --git a/addons/cetmix_tower_webhook/readme/HISTORY.md b/addons/cetmix_tower_webhook/readme/HISTORY.md new file mode 100644 index 0000000..ec35cbf --- /dev/null +++ b/addons/cetmix_tower_webhook/readme/HISTORY.md @@ -0,0 +1,3 @@ +## 18.0.1.0.1 (2025-12-17) + +- Features: Improve search views, implement the search panel for selected views. (5139) diff --git a/addons/cetmix_tower_webhook/readme/USAGE.md b/addons/cetmix_tower_webhook/readme/USAGE.md new file mode 100644 index 0000000..0c87263 --- /dev/null +++ b/addons/cetmix_tower_webhook/readme/USAGE.md @@ -0,0 +1,3 @@ +When a request is received, Cetmix Tower will search for the webhook with the matching endpoint and authenticate the request using the selected authenticator. In case of successful authentication webhook code is run. Each webhook call is logged. Logs are available under the "Cetmix Tower > Logs > Webhook Calls" menu or under the "Logs" button directly in the Webhook. + +Please refer to the [official documentation](https://tower.cetmix.com) for detailed usage instructions. diff --git a/addons/cetmix_tower_webhook/readme/newsfragments/.gitkeep b/addons/cetmix_tower_webhook/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/addons/cetmix_tower_webhook/security/ir.model.access.csv b/addons/cetmix_tower_webhook/security/ir.model.access.csv new file mode 100644 index 0000000..deb7b7c --- /dev/null +++ b/addons/cetmix_tower_webhook/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_cx_tower_webhook,Tower Webhook,model_cx_tower_webhook,cetmix_tower_server.group_root,1,1,1,1 +access_cx_tower_webhook_authenticator,Tower Webhook Authenticator,model_cx_tower_webhook_authenticator,cetmix_tower_server.group_root,1,1,1,1 +access_cx_tower_webhook_log,Tower Webhook Log,model_cx_tower_webhook_log,cetmix_tower_server.group_root,1,0,0,0 diff --git a/addons/cetmix_tower_webhook/static/description/banner.png b/addons/cetmix_tower_webhook/static/description/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..d90f0e6553d39be0537d95d35c5e576e5307a850 GIT binary patch literal 33541 zcmV)cK&ZcoP)a<9qL|NsBZ(bFq9b1ph`EID&KMtMZB z<}^QcSH17wYx zSAjD=c2Qb_I74{n=LQR6KvB=rm=DxzzXKssATY_L}mQ`Ma@bUFpVudw9dh_)6 zrK-gC_xaJ(;Yn17J8Ob7KY4F>pio(X>g@4DPJ>xwl2~AcSz(2`!p^C##$9KW-r(t@ zsltPct>otI!Nt~nft2Fp?BL?++urBZ*W-VOs&;;*#>&~BrN6hj&)3@JnV-98ahpDH zhg7=k$;{g}Uw(#;u)Dv~Ojn7Nnz(Frp2W!5&d}boxXop6nt6ezk(ah~e51?G+_=5a zj+L}dS&Bwcg;QLEkgTOo263e|UY8G(mZzqoaC$l97{~e9ZL6%i4m4l{cy3sJ_aWsl0fA zrfzhOn8MbWnx9*z)N65#K}>>U!tpPq-lDt#`2-E!Ekt+EJSz7&C!jWwK-^kTd>-~$IO40t8|N`FNne}Zm3JE+lrE_I$wgR z!_Ko#nrCVO&LE2bC+T5M55(rOC?lm(_NQV`_)t@-7YlO5$>UK#G zI!7$tBRA4?P%%TOSkbAvjt8n85zzIv9U1-?x33Qg0o|MNJN=0xMrt1+Wafxz0=d6I zES=CLis&)y*GDNH92W$mXZ4}v|GLrMpAwAOj`@xL#OcWiKKpiw)D=qytC31;DBMx5k1IZK!ERYK0rY&altRk2NR^fjRv|TB6KbLrC4?+-6USg?J0$?KXl9DjbIUNVkL7|N z^(W4ZkZLqSH&#eFO@|7x^_W0k0~99YLZzGd`Q!v}qrPT159RHh!#}gl`9Xi;u1@*? zRuh6q9b$8X*d(PxHD&-+sPH=jvm^dR?a^^Q#?4COovR%`=uMn_#A6RgwGJWW=#Uwt zqIi-}NUhM#>;Hn-N4>AyqYq=}~VU9m(~ui;`+>1On#>J+XSx`2fpf0TvQ@-Jw~Y7BBi@x zu1ciTJlLReP$sfT@cyn{fluk~c+Lgx3){@_>h^>$`V%+K5Yj44cZZx#k+Mg`qA1*E zT<&^GoR2;wpYn5DqWqO!-plZ)sxtuSPh6o8dYmD&A0aiRL#dJ4$H4}bR|vMzhuttx zS!?`JtJdL*QlPIS8nVqsgU&SmjOrYZZrtxkoTo2IhtzI^P&N+K;?=rfgGvUlN#GKv zwibV~_8J~kr2{uB5~ysXAx$rmU@E)UqlY$FC*UBe$+-(Z-uOd!ppth19(LlloS1DWMcLI6j;g~foX9DS z)c7u9H*~NLufzr}akj;uSyLisD$wI!vt~ZN=QmVlARkA*pRruDRX}gzY>7~uBQ#wj zHKs$7A$50*lnFSf9GS3tajHr1MGdPMqaxyW-ha4r=Q)3pZ44yZXH}M4?K=|ZVX4v? zc?V;dDU(L5)<~6h;C82cnKM=lbL|?wr0oJ3m5e(!cOTvP>^)tQNr6<>F7U-PCH;vj z6ha5{|2s}lbjXo%ZK%`Vc@=JJAh8USGzw2#GeRi08Gt--pupq~33XiK%e19GaWPU! zpn@W2PI9BlIM|@l#DNbDv=&cnZ4oFZ835ghd-(7@{akJlS>4xo@;Ih1ae9J~TOgzx zq;k!lC5{tR-K#kr+%(+T?{dm8vo)TKnkxvDHiQ1e-Msmczm!Bq@IGuS{LppPmpEq; z${a#=8K$17lN|rHF4#(CVFDgW09R}Ln6;^R`rDtlhc|CN=Wj)^mYm=z@#FfgFL6P*K9{jIK@tZ_(9j;$34YwW(UZ7og-~J; zI!rk;#U8125^VcBzZ&osm@r!6$7-kky&GLYpu_>w!trl5b%YmLt-BL(kkndI|y?cm=freO3q-Pvs3KF>;9wdQ4}($BW00`8Vzg z1WTNJ!c@LX@If_~x)P^G2wja4N_GtPq+LGC;#aGu@4i0%e4T&3Qasq-*YS(hll(ym zZ-95N`D*)eQ5|271iNw4LX?fq^}l0L)dn9H_vj$#eD#(%Y{9>9^C>=zFLxzQ{*kCk zgl=48sB5?2eiPq<2lmV3FIPCuf%q1;@2zWQHEc!?_@a90faPm`F}jA7n}(`G=?%Ih z5rZs8_^|%O+}*V{QpI5azke3a3j@RKypUujG2{&lG;L^$y=Xtc)>3ao7VUyp`3QpF zpwgnfv4}2K5JhA!SM){@>W!ioejHO1_v~)A`OiFOD*i4?rrAwP+b46*|GaIZf*{J- z#4$9WDh=XW)M0Jn_<78oDLDM%I9E{aZ2^so=)ujS#pB*q(r8=&=IZGb(d^M#uFh}~ zU~UE+Jea$<#Em~D7BAUqfFX|!WCkSpbSw=*-7}acV^k31{%B5@0iuP|5q5pBW08z)ba zesDuB|z#v2o4k}bB%@hRR#37DMg@4?Q;Y%2gYZM@j3`jiCm@hA%e*f5Y;`>J@{MtCVp2)AukRxSU@L{?s{AB}$(yW5SW$c%qZ%v$X z&z7bw$_jfaJjjWQK+-Lc;uDbc-b;H3C5d~CC;{B(UOBPZ^!InMB7SlbgCMpMR0N)I zI#viDqY?bsUV@S~QNdsHwF1AG+YD#6@@hb(2XS-jedA^yUTx>z3#Ia+A2+1*45@&2OTVBtvDtUFuO?M7LsVSSZ;c#P})J<$pgTo(PMV_b{Ysl(P z9RK~eQ%@Ok;#vwE-2{m*RVo9#ILoX1xPQD%oHEI5c^zGT_XOo7J*;!MJ!uDU%8zhI zAPD_yrny@GEiUcmC__O|I&mM=lsMB$QO3b37;@qwkody)CxZgMs}m|P9# ziOY**wzx+0BnVJm=MZacwkLy4;x5M2f#nzWAB>x{Kp;+3Rqg*@;!5``_ahL*#F<@? z_`!iwzC3DPp5@g=|EuLaP9vEuuEka*yLsmLppgh~SbSAVc;Wg6;}ZW{+`~S~$fh8; z#2F2QcG;@j1@;<17!ZkLm|h6Z+Qg*|koc0zAb=+~%c~1K_$P5+U48v9nJus9S63DS zVjHg@+v@dRngg>&Gl76?K_q+daL|XR<^TfvNOqUF-+-(G)#bbA5%B?mKjK)$c)#tifDfb2m=jtHFQqw;`sRFej>N(0;qM zRN^kJg$^}1wuut@ryvN4;~PFr1&;1PK;4jajQ{W(>W_zmkq`{F8z(Q+X({mfJMeUp zL6%xj;!L@I+?vE;E{;*e3WU(0ghxbMLP{LpHXMO8-3LM3AJb@%>==xo0c1u`6+C*n z)SyG!ga_|M1N|{%hUseRPJ+OoT;di~n4{1TkVVv@TY%saS7%p4bs%8|2Y7h`Qoj$9 zilFz0j}BshS*gR;TxD7 zMiEGgK;@`p zqku@uMWSknQ~Y2wow(xwq!13dEAZxT{L#VG~}0u7N~H2`bPE zG5Ewewu9O=AA#T!SKm~vkRKdSxC1%|DclDuy5-ZSQA|&&ls7-m3~1VS^!vC0iLx3M z#2Fl~#6byfbBz5ARjo_hW(TAw1xI&5GHVycXppiINP73Ss?z9WXd?wyBz#p~e*`bl z7_~A1-2L$FKdOGP+0nNC2l``RLPro zMq4P9r6D@D5Xg!1KlnvikopnsLI~E-fFP_bbUW{wFu|Nnv4YB!iohE;An+}8PMatz zZe!__^B2SR>5sqPtLobig+)}fcpKHY#ML+eUPK^GnjpnkBL_+Mz=|rwan$P`w(5A@ zz=Ec`@G`KFXq2>x1PkW+{_{Ti<{sx)gJ-NkNhW|~D}q2woPXIXjUgkp6=L7q6zu%*M`xYD%qMWb+LDJPIM^0P> zl5K!AC?H_gewYHJ>;MAlZll|{MR$9$SMXE3BJj31;K9hV34+NKt>Q&&PIoYSXhe;c>cmN+eBM7X;3Ss{I z=jYxxIOpRZUx%ZyiF&Bs1}bogt8oQ*D~EvTO7Nj7G)VjWdZiRz3wDd+okoIH(Or!V z>_qU8_7v6&+9<`iV7~Kw;6AS^R8;lUIFA#vVyuZaQ29)c98|5vF} zl`WjewY_*`P054&Bhc(VIQ#}+(m@xW34tTLj~6r)N@(U-udxq-kT~yQ3kv~NsNlsQ zP5-9u%wBbf<1l{z&~Ii8W11PxIODYwS+k{ZL1d4}PJ}Fx5*K!^NFqg{B(huxAw;>5 zt(2wYkKwhw<7MV~Ju?$Nw};`JGtc|`ZqN5e;sw=_{zuuN?SHnE$E0IJ>X@9RV^M&S zJpk$#=2%ur0vC>_a<~rV@Ng*7L^Xh8YBtIE}W{^um76gjI*BpR0RIsB00 ziqk(aEf*r>R!x!6f{5;q^_4HSesuO5FtMK_q;dL=K)p~YenzE^xT_^9?hKl-!D)|K zCKsSZvK6N<%xH@tG5{h`B-sk8@W=AYW$kWI>1L-a<(`mu6XUIf#d5%5)}LqMr^yi~ z)9craga-M1*1YEsFu3A;5R`_qUX0hRF(V8#^oD%YCX^JV>Crhu{D4KWoJl@(1`aIV z`!)tY^M+(`G6(Ssp+RBDkXyBi^8;wKuYyQo3w6v2{jq^3d{Gjz?R>VBTjUp3sTb9- z-gys;heZw=G@+1eG$^e%rhV!Eg*&L1m#OvK6Nfd67GRt9Zjbu~|{ocGx&n>zMjdp@A#= z4;TUoe-d;?yC{l5niF-d{@}ps@|p0f<{9lI7hqSUaK$Axhy)PvY=}HY^aX(_P9Lk3 zV?+J6#t}lZqRJ&Fel?{^za^EMrK5ZQ(dJ1r06MdJl4+AxvXH;7IBGrRtR#Y=iVH?T zq#YvqnF4urp57i*2PgGg`NFu>hJ;n?LB92}@@I>5P}+up^A9MFoyDAZ7Fr=war%&6 zGzJ>_&IQY@WOd#Eh$J<^jw%#R*uUxa9=&vI2*xCO4(YoZLcZSX-%uQ=;!H#$1q<15 z#px9_yQ2G1)t5LjAP9HvYtqoCD*3O(3o8xUf-=9Z2aW-Gs@9Iv8}e-t2y9YW_$nhU8wrs(5(06q-+8^Q z$rYy$GDj8&%9ay7+)5xasT(LrBVH`=73BzJ!@}lc^u5?P#ImM;!P2B4I6P;3A`SG|H$ia-FE82>#wa!$W zenIM(1RDBXZB8}*Sa}JcqbwuQedW4;xV?vkfj(Riv|SB>(XSzR`$HnP)5_V|xxu4+ z#qI2r;`Wcny+t0LL*k6~{Eg_yf-6qn59@-UT&$x_u4VAp@>B{Yv7a1_ENGf{&q&h(IXcAKNaRRDaD^ zKEQmnlp_N=c3$na{$D&^R) zKSbIRy>G^|Lte+!zfIG7h(8Lg63i=dcU6!ekC(p>D_d9G?}N&hGTP___q#pxfK(Qs(!omkr@Zh46w10mw8v7z1^h%IEGngHX^x0LrIMjidi zN`&<<3t{EmY{fmwO5@T&CBIG74)oQisAWZXTydiUuB|CMrkWY)vMuR8M=+p5VgBG4zWXMM53b#YHw=!0_dv3>U<}VWNB7pCihIm^0}lXru6SL!zQBSjjwc4zk}Vy7 zV%g!rt?nE@y>8w4;{C6|YxbUQ>B1R&Wk<(6cHkF=IM7Jih>=IbCnDFVjReQ4IloE0 z`bLm(+Iflj9ZTdc{h~OQ#=WEVEso_W)g-RCTJF(m_6+LS%))7?Z@f+&vll)}T~z6N9aw@+U%VZ-`wk~_`Zp^d|4 z4Spf&k3V2S{VAKKfYA8ynnS5PZf(cUsz1=kiehGfLtyPHVP0 z3q>%F$s9XL4V~mDE@$9`=hEE_@f!i-36#^=AgYbbtTgtStdob09#YYHzxRR!Xa{l~;3 zd$4`&ddtSnnP%RZr8pi`z9?r0vk`uvL~*1t)@yV9ZQqu%JX|UAV$D>YOU}P+!IBBd>FXQ_V2}X%iF6wv5EaewtWaHM5L)5dSPnY|R{=jb)_n0W| zm)^krMyesyPQ?BuR~)y1ep<0E zVH(Sble@A5H*g8}H8_;$L?br{plbx;2z#K{FAT;&E{$sAbn;w0t(BtWq(b+VW6`mo z6~R>(mWaY6!oR@NF9 zsQ|hnhe>H=I&m9_%vso@HheS#5K;)lQLC!tjYX;^A&ywcOq~8YF^tch4q7v#{~OOA zt=@Ynqow=}zMmWIz!DzU5Zy{~A?HDytTj%f9*>5qB$Um05hsgAG#k@3B_yz7Cya-i z06q@Uh@)4cE2>vLN<=~&B{fVZE)*=mRq4Oj^nMNav@mOfG7}&!#YO)r7A??s^0~gluG&T(FcZO zNi3V>JgFZY`QY6uph{lNETq5%;-sx{(s%>?{!d9J4uQBK%*8bd51$*aU0DD)AE6OP zZvl@qcsYlp$cfy^B?d>1A17Z`&M}=EpM#uAx#_Sd?suz8)(EmDxmiw}djfp6fYvyT zz?45*dAk#*RCS`(1kmd-c}w}3x}ws{N>J`r?zj>sEaN2mEoh$3SfrZV$8nNh zxESJ25r`8UU@fuUHvILov`E}W+L8jM1_sOxS?gn~!)@yca9pK-aBz)3WWPfLCZ)e+ z3!G{nY^2yYwQ=pq@M`P83pMM6YJ`z<{bPZn#nuN~S{^HeA}>jD?xOH1+z@4iNj5=>;Z9Fhy zS<#xpiA!J1;wJ?z-Dz&mF}Z_x#^#Yh?_P@W3p1+i>!S-mOasKaF!Z|)lvKL6Q5~-T6lmEF!g14TOE5xa*>r);|t~#;2OZoTrjPi zUMO*D%dLQ|Yt4!CL5tpNV*A{{*b>#LipTgxV;)uFXxHT$th7)&t z^R;3BkVh7$`omB$dm8-hzK|1B;NM7=aes@mpu*Fs;CDiCxQiw(Qb8n6`NW!(Gkp%S z#y8xF>sez{F^)#&^^3-Ep)_tH5 z<$0a5h!f;IO>#S&IJX4&j%#aNkv#yqr=`*em6hJa=_KNKdNcCCv{ds`Geghg46Tbw z4sjEcrY_u8?5Ws%jlFJSqSr)jPJ`iEX51z^t#;Chev!Dy5I*yOIey$K#TTI%8>e<8 zO*=Z$dC4-e-PSV?@DE2L-f(gcgIW{X#h5wrF87i1o1QR@d*|fjok8%WkeOOLK#x{M z-vqkUpq?*rQ>GNOH}fuA?!-w7P6`b1s?_Xg_zVb=6Bnftr?ItoX?seCGq7@pbaqt--X`?E#z$u)(2bT0K zt2a}vnOYEWs@Y;YeHA~dB^%GB`w{+eZ*K{}NF#8yBBdRg*U0%zW2HcEQ%fvij2;NP z&iZNNKHUFvkiucxz#1*(<$QpN0tDiz9R-k!wOypWAsjN@zW+7LA3 z-%E{Rc7RVs#@Mn&L4vh-&vzIkWn-3`dXAaCvm$X7_wm2S*1^>M6n=JVQL)OFUWB&E zqDWmF&oP^g_{V(2SzY5C!C8EF3&=*!ZkmzZt%93q+vCwsWufmZaoVe}qDq(jbnupP z&Eeif#rqMP7O+DhO2%An+Iq}l zIa6Ru$IDG7zz(TSJ<-;p?# z^jH-=sw5{a6r|d?D4H3Zua6wTyd_49H*xPPVCyo1!+Kn8sZIQyoH*1o^ea;g5Os** zpcYzl!X3x1G;lGqdBa3-5D7a6w!Sp)jK91eE#wS?#3O3ww=CH2gRBZDk?$R?ynP$L> z|8YHyBbEYjWt!>BO|n6yg+Gl?y%MNDsN_Ky55)_=LBVdp60$Nm$iykqW< z7q|@$#W1*V)swi-9iv$5!^K&nBMpXAe_kN2TD5$)D=M^3qfgmVTwP`3n2-Qq@yNhe z!1T~23bkN-axI2d0e};E2d`$1{7cu7^B)#G8KrTlyW zZsn|y0_V%PBAm)<&!sc9=tR+8f*-t}HryAxMcMzwJ|z-qwI zU6)BdQt;h(feiZ9Q zj0^N^=WHuh+YTr;@|$iW=QlkA6um(sj-FMchg>v*{S(52+8ahoc}4zeW;+Egl$^+_ zn8gny;HU(NZ72Q{7kUriu|S*^#LIXRch*VV4Z+c5iLg%L9YMv838G=@bzJ*+0mQZ7 zTifjyIICo7{3MJDJT#+^nQEj#gUf-+}i2+qkGT?`E#&=)1Wt^xT$xAZ|i<2Mdki z3eMD*ngcFBh>MLOGzy@;VrHtp4_AJ?!fO#wZX<6VWg{1c=_hd{qr$5rD8!L>f!DpV ze6DurBf+iY3pugmWVV>2h@)P?O0ndcULP(eE=;y@bvW~YvNngN=9BP;==o1IdVi2$D>JnnBppafo=*^wN!^sncjBscZCtHG zmritcovnwG5ElfnyLK>c;=hfyKHi(uyC%oJ!~ll!mxU#jfr`O{xCdIJk{rnQZ_qYsFBu zRxp5KRd}tWbBWt<8TktnS6+&Gq~I|i>UBwH8EG7m;Jy>*d3HkB%da3f>ty0Gu6uc@ zL&-pGH-zhWtWr>Eh|_t`P7lk<`ETQ30aJ3H0mQBu(b9xf>lI(c3NO2ggvTlc9Z=%v zdvczn@1FG1*f?@Pif=AVCr+66wK3SE#54+U{d1>F{__$MaC;u~s)`;~?F4lD%3N|D4hWD9KFL$dmn4z%8O zf^k$&FF9Iy_{jH>IQkT?I11oDahm6W$H{ zv?xF$&el!HqbL5a<1C+YNX0G!^ecGf=@x&@a^nO#)b`Evv_DDlR5(~8 zzw$q@apdx_LkG*og{b?Wo+j8hGI86uS{khfr>+E8>2LTwEQI1!QKI^p=jHD@Zjtqn zx9>(QCu2pe<8-{=F-L*1GR3QxnV5B3!4+SJ!ZQZ|3vo66M^3n2igjF& zx)189{anXIa9YLpSgB`YXk1p=Iu4q-6%@72VhuhLXZe(=`-~nWBe1C!W8>iJ-uh=~ ztHlnTE#PV~083@rj|E@2X7PiE6fos6^8Z?PfE=rYgB7cc0#Q2Q^nD6(d`%PRTyyY| zVNIO_EFTB!=z^Va#_W_t+M218_{xZn`8b{Vlv$IY^>Nk%UW|={80#Cq&&g?B5|(!C zwo^G4P05(C(PTGpd5pY-#IZ&9fyu}H%iNXqHcmz1_oM3GYgw{P>?moLcA5n>khG;K z6lg(g6@{{+vUS8dWf}Gt_I<0SfCd4>qJfS;AT%1@Mgj>5B;WzD{2*c{#RNC^B%gCb zKSUJbnA-9E?%B`L2ISLfKgl@lc;Xp=jz;2fq0^v+o8FPpCwE>L$h}M2d6gud4cl2A zkrd_gzVXoWmqz;8nGcKMz@N4C+iFHrO^-FL0Vl2Rl zB+4~I1|AB_q|d+OGeH5?Ukbi)r)oPZgLQZP4Od?}F@4_j5E%DX*vsqiKPSG}u1+05 z#?kuuVV{+Y-|_>Ibj#x*ZQiFYym{kTB=~k)4s{iv>h64h=2fmy2m3Xz^8Md-@zd8W z{LsSJ|HX$o{O}K+qd26FR8pICiDWY4jFV4Y*lFtc+M%bfeBnT6_xIZMagka!cHvDc zuiSh1f*W4Da4hnopS>~`;`(zQc;KOz6EQC8Y!Wppr<>0S*8G$G*l`hi9%(lddw%7H z#~y57UF7hLydqHrcSl%|)j zhs5B|BikemAjXjlIv983%tu3cWj8yF2qaB69{6Zi4qO?1c=tq7#=R8l$$a?S$Y06U zfJe>4x^Pas8_D6YrJkfH>OR(Px6qAu9_w-E#u!&0eSO@dA`Di25Ias%fcdLR8Q%!w zY;E^w1={zHm@I`_XN|*aA&%z>^Q0dH0jl4fW;?_jg8$ z96It@TyF~1*K^*Y2!W6Ewzp!8lRfWTK5-zp=wZvanId73a9C|4#Wk$OVYesqyXip4 zm}Q)W&a=XY>ma?nqPcD#9l7k;XD@sBoe8-&`Op6Jl6#-{E(aZ-I8Gqa3TrHdJ3#PlTns+XTsrASIu<@R%{b}k4$kT1 zpAF5{!x;yZqnlgeSr5{;`Bv5#ibiH~EC4;34LU%o@Yb>CiPq%Oa={~7R74|WoD!%% z%EO3ep zaUS!rTJv7cYUSF78{Iw9B>C#E|I4@EwMN|AeuAe1AWzaK;&8*}d&k@e* zvrvavSX$p$QR+_B$(7mo%HbJ@Yq7XcnT4(RITOChCL`ss(PBmIQ-+)Qqd)rGl98V$ zGh3UgRC^AIBMrRV$QUP~v^ri+Z^UlFEaTP`VQ9eaQEko%Y~rB5fR|+)AQ1c_laLR= z*SOUXebl$c+eOfpM*oG+cPg)ak3=rmkZFGu zk8)F~LCm7YbAGj4u2^XQa4tY#o^i@291BwCWI!!j;E^4Ji%W8!&CI5+TIp0ol~ zP6LXCbV7lasnl#;Q7LKQOFiX=!Z^lEvlMg<$#bWAG7e8;a0tUv`t;zGr|;UvaQ zbZhcp$9X}5*NT_jjT;&R6B&x7Py_KC8SO~82w|jv^oGDVrTZhIAc%mr()dozGj355 z1{byBal=VlkyS5DeqcG=EqcIenzEsM(rF{#LEfdEa>rkXdRd@7AlFPy-FN_;k(Q z3B1iRRW0NgIuj`{9RU_&)l8!yXAhZ0RY_1X=oN`;Qn^yaM`!$eAfyizhxprfAl1h^l;LCtk1!{h71*}W$ z&IT`6uC)`Ffn{bA`2sVZLC=X0Z2b!iPot*m8E3)up)O%iuo@>ZM<(8q5l=eCe^)Wi zCjm^&5@n9MtHHbW`}Qjlsh!hvfNW-#p{+nPg&y zK+MAlS#_X5&Rp?;7&npvne}*6c6IakadM9x{j%D5-+3k4I(~Fhwis8~A=*~K87Fsk z>@Yvl`~3J!>g2Yd0pTnW1iUF?{$!?a#xZrMxQ*#1Gvj(O4&RIYEdp^ZtjG#RMSzfA zCJ-)Kb%O^TvOYz@W6z7;w_l?VWA&6Zi{#FqCyxr!0O{%LB(3Vo#niXJ1)Gp@o}^@q zlUiMRp+Oqx7>B3a;zDI@jtoE&2z)GkT9`;WNjU%=%`_K^$clR_Er%G0qEB5YY^%W; zXRQf};E5#hZ5}mNh3Uz-JYE1oAaGYjO1DrYMF10^_C*ThOc5{-lW#0=dWD@y+-6a# zWWLIn_atRwiz&G;f@hr1n6_qy0AcWr6v*Yv1fpnz!9(03gFh~BicsXI3}_cjtrehE zW>PVd72NV9x)Cmx!PCGK`rn(@G#?YW-P98eRv?s@;XLT zUZ!@A33;2;{=nU=)qIL$t4JuS{1Z!9ohy6^GA^dT3YjMs$`zEL&}fQ(xIGysV8%7j zCA9!M2eUvI6ob)JOgnGm{E~o?- zftz*?;`OR{q$;_O1B9Q|5;@B9nxxW+E*Tem&{M0V4LlB7FOTBch?FXxv=cB+s$737 zkYkMA%1f1rM^DD#Mc@V00#uU(!tnqj&tTHYf`S8bFjw6u%WJarrp>q$J3F<_&IiGN zSi|;z%-b{yh}9D0If+a`=}JnBltnr$yTqd><8Uvw`X8#U>$n|$ zY7rv(xB0C#VgUP>eV_Y{t$r6(N`Z{CeSg<`5`h_Bhqct7V(yk<96Xc4sS*afL9h_O zuxEos=}r!%Bkrs$0@5bAA?NFZ;3B4m!9BC(oKyhgJhjVdcbR``iC{5)2##@h=_DW= z;Z$bC;cH&Y%~?jm16Gbny9BY}5R4@Y@Qq+lSsFC2Qu+>!47I-OmxyuP!oZ6M%ukT> zbSLIe9^+cBpTWZe=$U?&P?V|!BU58&0^C&Z6wHE<129gdY(f1(I0-W@C9^@ZjKgzd zeFA|`r7kckCx$Yr|EV+{9)K%-TtbmLNH``v>JPZgKeiUVxB6wArz|O`3;88qmP&2t-=p4U_>lCu?}~^FCnAOtA}Gvqp-HQDWH6h!{tivY>AGyxvQ8fzuKcX`o>oo63Cl%Qrka!Kird?t*^ehQpF$ z+<~#Nu?u3&p<>51;HrC_d|!VYD31nnnQ>uMkY-}w_B;khXDE@F@GFwgpT?nac8BK19VQ~8Ofz4 zAIV}N_PqLPc4&;_E0%ExO;nO$9P4j8)LNsDPm>O*SqgF91Fi2~Imn`4b3n$CV5*2A z#^E7(%KD5a6N6m@1gr7>35wGNWQuq=l459_5W{DjNarcSq8LKJE3NUdF^pqf(XX`D z=((5oC4%o5YvH{?7}v<^j^jWXhj(7Faz-*MRT`!_-#-CIb(w|$;VJ5dJW)!dRr1~w z+4_e^+UG=E|Nht*fwI#2J3fSh*>vgk`ClySP1C>Bx1;Q_+B1$LeU zN+P+=_2%1hDPF093II=?HZup@s)^2Hawq~`Qsgr0M8h~f0e<@N)*?OR_aWoXex&uy z2d4*NT%n;mj;A=GjH_=D2rmmrDxmTXG30Qy61U{?eO+2!cCYjk`5`h69|NOUD^9}) zhH?B(ECScbflu|(op8sy{hHP{&liI%dZ%VYt6Wq7UwR7p zF^n>9j)sF$gBWNS$8XcOjJ0qJ@otQVgD*ZjkeHb%?=L%!88HiIR^oYHa*=8E52KtT z2FVs(P66=`V~kU^IV$w`Yk6|0I(3Y3tnPDZuuAU~q`|Tk&~Bymy=$%*Xwi!bz&KtZ zcWjh#HH$!G@@A@yU8WKihMUOyA*S*)97rKYA>-6J(!rOD4rBZr31=1yfOQngH>VjOvVjFe*OTCg*6%$TsZ>9WwQPKR+0^# z`u*ie;EbaSTC4QJkF=UWY1gUwEDg5k%eD!v($AobD>Mj1K4XR%DF@bN`Thw>P-agr zYQ7OqbF@w?gNzdn4GUMfGyvj^<9oopc_Ev3C8EIHV2rC-`^%0?G0sZ7)g20%Wx{|k zvRjk{4)1zN+teotJ{{XKC*v&1G**T0n@5asd_eWOn_A2CX-}s#tr7WG#*iGDMN_FajH>z!(CeOh^n*17tjz88dan z%_U3Be4#P=!uS3MzVpAa=k}Jv>81U(r?#{ zCVZ48)QW<*)&L4M*N@lQgrHg!<4WprftKAkmA*q>BTqM>jiW1-!Yzq@dQbdp{g0lA zpKgpTl=lJo8Rxn6|C+}YkgRMhQqi#%08w2g%`GnqL20Gm?+zjlN;HR!tM@>Gliaz6 zJh2-`r`y!yL_ggd0XRK1uK3R7k-?no4H`mMiJte*J$o^dsne^m?U|X6V_L>|czbT< zEUMs2LJen6=1%)2GnCxNc$6|O8rE<@$p?L>GjmU;V+Qg>D=kt|u~Z`xHqw;8hl237 zDxh%tK_E`J6eo=<3n~x^`cVk(R*=xf(Ps-SB>HL1wlM0YuhE^>B))P2&dBcm3j5md z-ukwBMW;6o_Ltfd+0FURgF0Ll-*|AJElaG-&gif)GPS=HXVc4vT{o|c&hsc?+_Q%( z->FEVoUQ-KzQt9uym)x3A}>mn;vBr?uFq+syP`xit8sJ73GFvvQnz{6#Tg!$Mm_`^i}Up7sgPfWOr z6mFcNphCqJcs?|6CFRW^d8+{u*o>nm8!wL%9q?EMBegF_bWq(A2EEDkao1&*pQm;D z^S#UAmJMW$&kx};b%&!u>#rt=XlCP-UDL4zFx0Vjm%v^WdEoaJsXD|*Jj}{tRH86~ zw?Lv?uSa%%j-<8PQK)eqpN9NUKog3PtrEuNJlW`BqeYl^>W3}{chLVXdjcGTg?^kA zX=`nL_En8JUQ8*MJN^3S$w#+$tTAQu7X|kC_{vJ+1>^1kIO;%BjPd!@+n4)^XeQ&- z8>XFuFOfjou|D1Z@OwP*^ow9PF!G{5qB*LEV@yKlaIWgh7``o)n|gep}RT+{hZUGya_w zU1P#+_`6flpKr-X4i@8Z|?6TR{1C0~0>vJQ)vY}&P@!X$c|J%c{Q1*-Y|n}SiPZmv~g=6-4_ z#pCf49Tv(XCQpcu6q7uz=cjh}RF&>)}uMzNv zxJT^mpG}I<5{OZ|anldY8RC?NBsUY^INT?H00{rh3b{fXN6-Dw**LR$*{@yJL0=%rDtit*1z#`!LR@?P)ZACVJN-Bx0Y3~u z9bn-Z@c8I9lJJ4+jXvJ`@b=}~KGc2o)1Qe^6%e8}<4$jzS(PEaF2_eT@%z%YCfJPJusk z-X4KTY~H?4F3i$~3%t{RR>#X`g20nFUp{AXev%Ov+I5g@ojoOXYC?F9O~<|$qh8e( z6^xskXKmg-#RU^APZG5QobeU- zZRXDn@wx-^$B*$S5AvvJoV5(Mcr*QUiA})U4H6>rB%k3RXNdAHlV=FXbEg3rAm^h& zH#o&Gj>?{R^xVGPi#(u>qo?RItS`^;^Yk|Hh>vS?t^^xMa>@rJ@#KbC-sul{DA;7J z?;Rs8&WYV;!zMq4c21l&O&+n4O^oV(2vNZ}^@h2;u@wa;`61p|t&>ch(1j(X8c67E zaPs#W6z}XwrMd&T;3-8KzTZ?36vsFyo_sDo`@jcz?8ecX&a7c*9gJsjv%>4XAg04d3r#m1hX68gGQIQjGy$1;h za3-B~ zLNjYtDdzE)e;6Q;5W`ae8maYjPY@C~ILN^rQIQ)4z^VKh1VuE?>kc%-26Z784P_ji zXy)n0(dkq^a6kci%w$UHXxIgCu9bS&XYUG8kvoPM@~0;cP;5VrXmJWpQ`y1qg^7 zhNBgb+*V@2P6DN}3eB&CSedq;f42i4>Z5)03zOiVXaXyzC?7}T< z70r6!+-nw$dqd!E%R7TN>M|HrRV8bjr!k9hnwrJ9cb)(@+LQHP0{ZFBQB7*c!gCPh znUC}q;Dx)M{JHY@nov01*N__;NQ!TqUTPpWwCZw5KpICs_#9gIc|`;Z!VCBL6BcQ(g!){sanX5Lg=W41oBT` zTA+wG@6%{VQA+C6)C36#XT}{< z#_^@U8L+AdJQNJQYdE@)hR5B>eer1fM^FU69apeeX1MKGNfc zGo+wJ6vsGWd{wF%k%g*T8swmjqbK)ME>^qzBP-6=dAZj; zbI@DGH7_p^5zDmf#)(%KzvtfO#}ZM-gJ7gWtAEnAQ&-_6Z&rfC~goqU>tqVnTAYUZ&l#{ zuL;J{%L8I@Wap^4?}l&N8ShNyF1?`Yl(g=(57V&s4h7uhSj2q=ydHnMy z-y%*M7iNDRf23SU)wjndZ$i|gh*5jv)@*Hb&eV743>Io7fM0l}HUW{3bn4l!qzrcC z3#E&65MJQxyxLI$!T-)UMFWQ{Q||aygA-h{jd4_m5l?|lmUda(;|HE7@54gbF{?Tr ztGHm~&Hd*w0PF27 zH;jv94q{BzP9=XYjG|~kS5G#(Q4k&P!x1VX2J*pU{3phF0ty7E3zRz|2ArU*(ms!) zmk0Q@R$|V4m%X%Vz5HB|Me}T+&Y71A7?TG~s#%S9`ow?nf$cgr_C5c^JnQZ`ay4$U z(|kkEnE`mhQ=gl9KZQN2hN3*0sd4qk)N^RUu=eAA6^w@Rn-BQ;C=~R{n0!i+UICC# z%MdFBvXtash8Wa~ZJa-t?+M$jIvk<4)MgyLgewkdH51}4PHWe7RXzXmo{JH`J#e_N z+BJ3*H`kqY-syM7nHH})#~zqUxY~QPoQ*^5v*alGaA0g}KKaK3z#g65TH(=*jfa`> z;~T)1sp{b2ql2N{!;j1r@WDx^r(1eCDsndtBZDr1mgfM7 zXy(Qb*hl#c*j{#}v9gUkp^w%oRUnPp0b-I^7!7a18QjEnxnJKzIx1i&#WAkJ7lH_# zJ@MVr;0Q{@pV>J2PNwjESK76VJ7yZ>90|Oo;7qPENjU9;&n~xdpgdmY!Mp1QE(kAK z+wSb;E?la^;Y&>~rkbXs;rsT+)gD%93E*sixyKnihYVgnF;2;IU8hnj1))Vka0YMI z+eoW^(r^W3zM$jADGCq*Xlerh=T5fOWB+&cEKB;k~-udYyq|nQW)dP9zE07JN9EDL)4TtbVN|6cWr-n@LQUTRL6~m?yy$Ey(c|u zhZ|uHtz}}o4(Uu!r%MgxG%=f*eud+pI*Cd??+c=TW9?x3}CBMjsC{ePH|7!ZRb zBU_dcw&jCp90%JFihb`CU4)Hbjev5@1np2=qn!+Bsls)dp=of06n2M6D<-F2l z{W}bbLlPhSWTbgtX@36Uzaqwue|-PbFF$?%$L2evtP*{<`LAXf)6B()mgw=CuPAQW zW7$dDNt_3%K_DJaXv^HEHgO#fp+F%tOAu0;J$$5ej@ZDkhkrs1`QnJ_(u~ggv_zF9 zuhKP*96AIqZkRYVu!gtsr|hQZjJAws)F&?KBh))V$WCw~LTqfPA?*I>C&=ke?W#>P z=Qp%wv5gQ?U7Ly%7oUJOg7u6j6om9e6kFt@blB-aNe4vW+TsnW*@53yB%l(Ozxjzb?O zzr0e8XwiCXpfdQ=I7?h(%X~Ukv_;K*0O}JbU##>sLZcB<8G8fJHa6_j+ZN8oY1OtzEHdR(Qzj3 zWIR>uB#IVXT6XFaraJHT9zFivSB`ifFM|u+v*nsY@IdDfyyHXMxnY#tE;$ z)oEpNN&{J+y*d$1b+)uD9MANTCN7vyJu3RQ$v^JaJh>Z}53zlm-hbX5??&j|uD9C$ zI=xG7Q}_sRS$yGHI@DBZ6L;<*lo&Vb6mqKLu09~YamFMx(C^k($s4<#(za3FJ;ua& z(|G_?^j`oU*8nUwupQbB-1^VQ&m(YI0CvOG%`9h>1Kjps+flVXaRY;p36Ux|I(F@< z2#!@W(1~z#3qk#^r*gWOUmo9eTsE92UP;{Dm(6CQ7|tNMw;TRBdOm(00q^n48TNL2 zm#RDN4NyMJUVBtged59Zp}2$8O^A@Pjw*6yj$HHIr{q&)d`@F=p3<(z4cTzv9aZAO ztCa!1KF5tqSbnk_DAv!%&m#bxhckQs`3gSSF94|9@Rc1^>k}6zNM%S(EK*(1O+}9R zDPoCfT#}QQ7rKVV{Gp~zlixj1le~94iHm0Q0Wav^uKw|`1|YkI;=*pwcl!DGdF07( zYB$`aWOut4KKZZgs9K*m)kA8e5lUvb(M4=+oy|kbhz8Pxy>yS}Mk`vReqRI7FX#So zBu|M;wUvbp{BSM0<6$I0$^H^PTs?{Br;kUhek2lVgnKLI8`1u9kITs-~e zEJi3ABBmU*Rt78Nthg(sc|S9LKy&4U_RR0u39h!mw@;k1`BZZN9RX2P9Y2rNi`9or zYruOsZ`LO+i4pR#Z|^v1#-ikf`ox(5 zQZtV2$qV?1B_ELMx!ip!9pJnS4#BXGBi-@M;M*k58|4=UN9>j2h5E#KOQb@DP`Jg- zLx@cTZe@kuUljrt0!qEEf7{0Ae2?Ga-a# z_hR)lYRwB(;+*t(1xOXj&4pZS5GxbJyq?QPlB7vpSWalc=!O=|3tp4X@{xYm#GQS} zM;?c&y1Z`7@_7L428eF$1B}luwDfA)D@yv|Giy%MMG{3_#ZsxB{{2CBq;-29uk4VAcR?s{wD{1l=745`rw zH$sJ!S0HBwEv!$;yC(ymHmoKz^>DK*#82 zA3l9%#~Ww7;-JJCAE{HmLG?@Z*(277l|FJ-)t`0BEo0o?C|7-sn%O?ESAUXUo_kIc zXD0J)rg)>O%j-T#cp9dU#ea#5?uxbjYz#ZA5tTP4PA`#~rbrctS@!S@V&~V$`DuM4 zm|Xvy{rXGV5DgQW&>KS?|GK!$JV%K$qxpDbkf^%6UY=FvjDD}qDxsWGd28aZNO>OL z;O-dXu}6TIE|7~dryFx*r-J9s2=s?O?)kH#JdavWTsRufCkBbC%j^G%`~C6p`YCa2 z*8hIt18_0pb%!M`(+C9%j_yJ(R)}2&$aS>KnpxCgKV?1IVDbRmQ-f(fo&@bBPIqU; z)#!vqbaYUE;dRMZREcxc5uuR^E|F4auIdBJYvfYjWr2V}$bMl$E7JUo_hIa+_+Z24 ziPQe%DxY;V?_+Vv*Hnpfv~+S4BcuwzNkKS9%qtXf7MBGAulDCNLo32OMaI*-k@6?o z;%4G|;>7i{={zrO%Db7|D8A;f#CaX06k^*DDebX`?;%zu$oZkXEu|ta0O+2g6+v#0 zNdF9wIJ0FwEGLP1TjIR4&U9Ohr`aj*>H5lJk3$lNMJih$HL{nz+&5+}+Kp(?A%2;r+Xq9|l558uHVIkW;%jV5_u( zi{k}&kh6B7_v6x)TLm{F-k=~N{$`TzORzo96|{@DGtjJ6x&+{I7IMZcbiWY( z28Gn9cER;`IVCMKNxFn-l9FHC$Kp<}91)S?)GLf4<(qTbkyGg`a>--Wo|=A#fTw|( zWSvLp-W9jMF+_|jj@lsQkjh>oRyhXL$en8BT&=rcS-(SIQ^QO`-=cKSihFcT%-G`E zHBwu|9yp|YgN*)nKr?S#Z_s(%5|LHRCG;g)_o=w+TcXAmmnBH$2C3_oWvo4N$ADb^ zWZ~-(3QI)QFqc(dGm?3<=5Ov#aqjjdana)RZ>B1qB2}DNh(+#dhr-hpLqdtpx*)>T zj=9*o6XSe(%Rk(c;#S)$h>I7eKRytUIvk0RPT!IovAjbelnW1X?PDSi1+y{N*9PJ? zpL71A+>+XLOgQiH42Y?bRnkG#G5Sw^GW+a%-u%@Tz+wN9~ftJaiK-3^hlMr zh&jQJYsY-rEd%rn3SeQrf zM$Fp9rj_*x1zV`!+^wHiy3{eJqEGmTea9#lS={azm|}c!wm>Q`5qn^XkbdF=a`u5i zp$bdCxjXU1go0UReac^DpEE|ez~b!98^c8L;`Fz0qzLHWAk`QKWI1v#3lvsQP(NL% zpI7>pS=m>&MtAW0;)-kfl!+!3SFVxTtdS~jS%|U7Wm_~hE9KpjvrI{Z7NjU~XyGqH__&nbWaBVoGt<3aL6nDjYdW`xK2LXHc7= zl~qI~C3AbY-5XRcptvCZl9A$OJCzy`>(_{tjsZ!7TsRV?801`P(5f1uO3&QP=@?Ks z`{HW(Kt83oe1nvIiP*Cgu_R%LL*aFTTzrsAJX)@{L_KMko$q&ilYgiXIqTvMuM~^b zDaDZ@_7Jh;206o!)*)BDK_gTQs~uX0f+$n3n4NujOaR_~Lh5{ryXrQ`MT(nkA(v_% zJ8>+;I25`Sa`8c~DN*yDC|f(`m-Kr_@!2AFrp0C3TZ=-ZxY-tRCP(bHMC@_G0OL`( zdWJ%LkSlW3ia^w%Hq6iL`tscy6wa|YbLu@Rk>X}s$eBCDs+Wkl4MTE+ob4PM#fm}e zrw9lJu;vL%Xr6zusGMDKzCRgsCKX4Dm_y7xU@11Bu&>cLY#3Gr)S3-(NyQ@4E=A?M zid)@oAEPy`xN?nDy+iDUN6a{eWGI9lh4|Bxwnne@#I-GpSlu2yD(6$2*>x#;(~8SH zVh*ulkC;grva%@Lv}mjfh9w@oA`n+KEF;;z2&kM%aaF&!%wSq^wm|H4j+E_?%Pa$2 zf`Y$6BR*}cEfRvGr<=#jtX}iYlkViuIEUi0yH|l3Of8PuAY~n5uM(D$qj7wQhAkKt zD)d4@oGBV+_250^Wd)hFaKhJ*}-VvkDMFl-ah^PV_U z1ZI?cZunt+eJoM=pT)Udcf|}u#f28J2C=+D&Oc;`L&0=88m?xTt(n2*D|7eO6Gs`x zaXitxU;ddxCYfyZkUg-=VHe06jv`B83j|U@+iqPb0kKq|OQi*&LSkADV&zbq8bc~g zYE7y}FHB6l^V%0-d^55q9(HGTXXcsREuRZx$T;(t?AMuRX2&{B%~FTT`Mn9}bfgM)9$t6}WxvpPPM2=im zVN>mk6C6z)E>r+-ap>jG-K~I|V-OdU*et;g!Qd4E`!`Q4e3MILVcJzEKqC~fxgM{1 zal+HoH`{mS=U;6PEkscnR*O@e;3c+zo4X18l=7(uZ8h^=7`8ZNqv?aF0Hqfuh<+EU zDHORZgHP>?bM5TCOKHia<)sA07U#bfVV~9F+&*ygO5hgZE!}vrBnYs<-5`LI@j0x5 zkILi_OmO+#s4Dq(#0L&FFYe{oBoVE=diJCZY2wt*##giJQ?Scwad3bS|B}T1pOR?M zgco*ndBDru0|5K)rds$PcZpX6s4n%L9*$>BH7@ST8WG)p_!kOAhYt4aHfOmwpBubf zL2&ErLDh&7Y*3nD&!sw)*BydAR_^{w6*$XzdQgti(J;tyklGd3cSuCPEkMu7-taW+ zuw0x|1vifXZjk`^IZ%$|gdOkrV2jHG1D-nAWB5jRMQ@+f{pmG2M!4ar3=Xv@PFhV8 zEsmg+lm26H;4V(Q1a3Yjc-X&NsVXR^U4RX01l$ZCgR>^>bNeM2v!C444jA*@?r|88 zQ~TlqPd_D!4T~saXb2p5i-V0-g-ZoDR|Bd>bi%sBLkQ=f;O$5hbpnyr~p_1lEfSDd6@L{A@kT5mALp3YX`7@YSjB&mV?ky8NUU3^by^G z0mGBraAE_6ZrvM?qO{IS$XX@320gOMMzh1G!VIBT1&6Loaeu-RwWHy>Pl84bi&Mfq z5r-R)c8Qki!L+rZ1YrvtxQav2g?DKjrHrnxqNF-l#rF432p@MO8iKv-XC7*9+@@Nf#Fe2f3qR&Y{%2IQhM%NG>;ElZf`udP~g+ zu;}}rU`X`8ZM8VDw`_64M4zA}0T!|S3C+?t(HLiOez9C}5Ns63PI9a`8UQoeYE&Fl z*`PScgB2+bqF;n5a!`O(LeFP^_kxq!($h333pFdw-TSopy5C)JC9;Q{ukS=*(aZ{c zX?##eo#m@a_$BV*#2)_Q&=tIObN7AD;-vD$DNb9($+wG(OT5OJc`_^x9?_sUT(3-V zaG6c6+asXH>iq9C&xM`UFHQ_~^fYyZ1b~?*O5`&eK7vKlcZui&7*PBotQRK-xQl~i zqd56Gn98i<7|~X7h{y$pi=V&zeGxZNoN~#ixSuLi9O~GUaO00h?Wrwu&DX+u}hA7PTGFsbQ4o^hn?+uFlI}TrkXE92!I$#i^ZTi)*j5Rh&Fm zc(@fxMv>D`^vJL{c%25tvC-^O%$~g5j!wJ@f*QWueB(V|UB!!Y`n#GMx;zTB4vF3s zkamo#p&)t)d zPY8wpa24mZQCx?@Uz{^iy{k$nqOIc8Na5iQXtEv|hZH+8Q5>9J!{Yh{R7Q6Y<_JL< zZ;+(=KaMm){}*;jD5TlpQmjL`#_DGt4zhUYjhDnt#! zq>xX*8>WQ9T{oJWE_Z~3YNz?)#CF-!ay63v2R*82)OLdC5z>VF0|vpxDX;Nh;)jJO z`rf6qd6ePyXM5)=PMW3h?BDEM0ff!ru~++TV1*nFo!S?2Rm}{KP5dy59B~l1ax_2o zd_+LsBE_QFonYblSpUP!WFz0qj}3nw)!Sb}aTI`d6#e2Hf=+R4JlFZ+ zgq>ReMmE?X^!{zm&mL$hJltJseIKVjp!gIku3&OzG&MiAeanAVoH#RuGq3lj@}v0z znmIVhKA7b6h`0zur!U;`PUIhx#WgiI+`c;KmlXkKTswOfH5gK2RjC$5wVd;yI|L|Zz=ZQ|4}(J`{>lhi2cj8ZgjVl1sc^VhX(aoX_$?dOYI7LoOs zXiRT&&Ft^B6cqO&PWRGPvEpr4#L@AtlLEA9n zq`#HUImDILdHp)Qg*SU`7ned#eVlfS3K8S<0C}nGXYD>6sK^>3x|Q8YKeV0^t?Cr_ z9{_ip4$d!mkM%||UdL(2bDuBn2<+DElHTT;*~JD|!QtkKsIiR|r#rbArxoP&8y5#v zGe?z^W6y6dg6b4^l)qK^H4VX>`t3%_`=V`hmXfcbC2H2doW(7k-8ewrJz6X(`q3`OP1qu$gPD^4zahl1CcDqXSOf)ENifE&7 z4tvQ7Fst(-Tv=A9xcU4yMviD7=Jh4$RD1z@bn3qG6(zuZ=frESxWsbKEK&TY%vLMX z+}Rb;m9w2NTl>%ZAMO5jKs2FK+;OwOR~*BN(~RdDEnS>;bM5TDxq`z@51+tlabI6D zE-qO(hb>Nz`Q#+uo4;pSz2a80#id}HutsS^IHHV&;Z zjtyPg`egJ;iQ?orJH@$rS#f&fl{?32H`f;D?6F-O{4S&7UTr;LbMWAQ4bJ6JVc&%- zZae>#iy8V7W^HCdTa(-g%8J+Hj1=@j4|j1&$kIBFIiT1>7l5gIl>8P&J6RywjUuD3 zL~&?RUgqw(=M#QxzBs6&{yGk2irXV{X*Sm{4p;r-AdUmL(dffBzs&B2#1ISNXhA$m zFlrSQ545IL5rZfN(n_m=+8Xhy+Qe9DwcaKs#&{$ikG}NDf58X;EBAsCT^Dz!8uSwr zQODh1>3nyWot>#3w0c781}6T^4SPMXBo>ux?z zREDWSllTD}p<;|Q*UCTl+AI}e%4vz?+v828&8$KdiI1|UqD~U$VP@iZdDe+L0MmGy z9`WWK5{Ei-KIi@_Z^Q(?i1&USa z=yDXASL!Rq6UXaN6kZ3!1tV}k4))0q*FA}wBQhSk8!g(MiNnD1IP{;i)9; zkJCuo_IT4x>n74f*|DRu)wq8Caq~IOqC7KkvXQt8RG1k$x>O=czp-v^oj9DbNgUcM zH|sI}DwC#1X5tXc62<0oFYlL#^5Xzc9Is=0*{DN+z+`XUu|#xD;$Bnpsgn`ocWck- zdzX9A#BPzU5$JFz5=dNF(K_6VpSpRXM7jy67Gu;@7=r3qj3`ylW}b{Sc;a5Pk8q^a zUpHBiM%uU6YU)3OT64ScL5r=}CvMP4oJD!YRpo$r3!}>a#j-iyYrAz7o5ZzMZn~c) zLv^v4IM%x$N}IaDWB&ND$REiHoL)sHDFtQoj7_$W2m9Wf)vEMyleW zFzmTYTs3|ZU|t-JT^>c#t9TVUb~T%fQ_?8s^aG>Be0AKc>^PZX=RX5z-w3YONma@L zj|+F&x7TVaZP3AQWx%>Q-)p<; z=I*$cvFTx$c;zieucfqTjUjrmzPGvR`PgMrRbGt(t-`G_qI?ZM zdzPz3VaHrXPkJ--aulL?d7RuDk1G#NoVILkOIq)P4Xer%wcJ~@Q4~aLxdyW<`>7wx zN(ya*BWptg_)k0})g@U!wEb#s{rD_Y@?eFm7R$S7&M~$plDMz}$KK}YQ`8uV?ztW3 zYlS-#-USgdgROwiNSte-EfBZIgO1h)Awqn81*tD64Gx634u7EJKCU(5&<7L%?Hj<1QUl%3WUjC865=_EU$pm z42MHqvvx@}2V2k(JM;ye*JRj11^Yz$(8Xb`Tj^*#85m`^Zq*s&4fA__WUpO$SMJ!})n$C~@$@se8O9 zOWGeAaHqc?BEk}Ed8PytC#~;=IKKxSNg)}_OY-l8Lmq$RKlnJ8CCxQGa_mW*eikzx zflpQe%ru9Z*X4^91u&~KmbnV@&4V^c)AA4MLp9kIuq-AWVF*Jf?KRnhaucY0^x;gl_o2Won5p zT-gfE^8yj2o&#HN&@0*Thgh-1!OP%ObS6)R=|LGb1O^Di-6whjA)XB~#3>Q{r7;5W z#q#9HKYt753hC1G5)6sOC+8o}?LYrIw-vh4j2Xa}z{f$w#l$7Ha>W|Bri9@fd$)9C ze(L$SPlkYV3Pc14U`LvI4nh6fA@294o<<^oIPt~uq@e#F9}%A?!{i-UVOJ_-s*Gc; zn=5I_ziP#5xaRAFd*OZv2%LlnDFRz82tn){dH2%lG!TXXSV2Wz$~Q9%3?XS~u4uiX zP>V;a6-7bBg?HU3f{3^gSGo|dAPRz8!G-=*trlyg=H$$qcnm&&prPc=d^3~zGCTrW z&7-G@L*tB38|J7ea&+0p-rS>i3ctDb;C^r6OKJog7N-5JTeiU;wNPdRgrP;7vi5T15Aa}Q4S=C0g(bqzlqymj*CQWxezun~)PdRw>bgg+O;pD`c= zhrtkwYED3CY*P+lf%Qpj(Kty4^8aP-&6VCy$L~M?^8VSgw_lz=>@7L+VK~lRcz$*>br-_~L#+&8!leoicDCeH`*7NW@Jh(UlW{KN= z8~$Pif2I$@u>cIoQ7s2+a1~9Mj<7!E5svnnR*?WWg`r0l#dR zy8#3zARHQip%PRR@O9<*@2#HnQw>7n?G>~OOlRM>Jn??5x61CbUU`#6Glp5>rc7B` zMDRD|^12t4(xOt7fK}WDuWsWm+fL*pNd$hfIO*&Lom{%H_Iun;epc|2FKX=XiBn8j znHdlwFs>T3N(oq(12n;usKLg9He~4tp7JE_ZgGiE@18t=w08aa^|i;Z?>%?{KeE)u zdX_jaWhDX-4woR5r$JE~mBfJ+ZQu|AzsH@m6gf@wgw-Y}BXrwn5@+ybQ-_5giKEKO z(t@DDI2wYXQdGBY6g-wrO$&bY0P>D@?(<{os7>>~@+eBSy z(S)ep{~S)yl2LjD1e>I;YfG2;fLm_WNujW8p19=VW^KvIVIg1jqJ$Z$324b)G{VNH z!%YS4kTQ-y&|y3YyK`2&Z-RjZBV196&l4w1S*cSHLJI=h@I+EUrKkk$;(j#34Qfug z1TD#9BVZ_(7Nagq>1G$22^dja3Rd`@I9yENFUjFg1z_yok9H{ymL7&^L}^fmn+V$C zQVV+MNV0!BbdgKx!B|Y3W0iU0%0uvHOAz851hq?pqEJm7Xy&MZwb&zIzNXJpi>V0{ zy5gTLFC&xk7l$lxSZ$s->|=g|Dq|uC#<4x1WCK(iThNXjMl<4U(*7&y^GzRF8@gUfbVAwqtmgk_UeT^B2HCj;>Sd{Y_7}vEfbhW}-8W;W9 zSdC;<85)eZBo5Dd%vV|v8Uf>N04K)?Do^4H~3JxzR*ZP z9WxUvA5EPLl9TEEnJj6+)gEDqe^SAv?87@mZ6*4l);B+ zPIg}ML>+6_wUr+_XNjzh<;@7Dx+HFR#C+nxI0zV@&)}FG|E3jq?R%sW4W=ne%(RKj zeWMGTneyy?)t1I(y)Bd${Dp|h8(U|ti)VibZRhHv5)hK+4VT0b_fq+*;I##TFLHUn zh)jd`_zJ1C4%3)cFx$p@!ip+v=1f<%A$WiARS4lcv~6tL;T*pI=ED$6<4(QJwP11L zyc68wk~loJn6GF-P;WtbK44+WfhRMTa&+WQL9o0$h9^Hi?snSTnzvsPXFh@u6bMR# z(eFVjFhUD>$DKtg!wJ9!j-ULbx~JTSc@EY26K?cN;;?TpUuHnCA3=HEf^#w$5jA+_ zGeIJ9&M-~kk642?-fib(^3sY~RsE7U`lJ^;7=jV}N{-y+7L5d6Jt7qe8KzMo!z#o* z{Z@I?*mG+!!zFQiCx26eaC!^MqsP*+0ttKyNJYX4SGZjQULV>gJXmKMalXtk%dd&U zCx`h|0K;l9nw@~tS&VQY`vJMOi)J_LSptR+{WxUD3_$_hxa0bB(2(}j*Bjxy!C@kaC05n9mD_61u3HtIHw9?FCk`(y z=F=4z#SxUl3y_pxM6ZxQv+I#gn_(&wG*+cu75{@zm)ccrk0dUMV{ggv7gOb(219m7 zEUrNcX0MP=G?>a(1ylyw+UJEU-Ujf7%{ z5zJzaXG?FM=*GC%nDp7N5=Rc#4mk+wnFfXLCE^rDXe9CW6%y)7jA4ch^Q4a6Jh|^E z(noI^q;!=yrXM=LL7)^U@i`VJ4k^g9773Y%$y}wdvY(;bLT}3ZKJRtnuq#0D8U*zS z&NBm&Jz_*|kwUT0kj!mEtct3}T}WU0azkyH|4zA~TBIglC61j|;~W^yf?}S5B;y4@ z2~s%i3M6yetO02|+>157J#Bk*ylMfN>N zx)qFY8IrI&i=<+Q8+5})4jN12q7No31Z&aOl~vVcIo5TZIQHC0W~K z6b4}0?%u_GFffEk!sJWDS{DVaa=qZf{(*A2>$|Z3r+>NCwzLJ)5Z|Mw)@S8H6va0u zPBQ0g!J2z?k@{)aiH0cKYbDhs1KP!=o$r`9AVa0q;f;uV6OJX$?!ZVC7>Nbv+2Z_E z1sVC&Kvd|pKfn~lu$Jjf9qfMMz{;iS3@^T)^pO*kd96WH+Eup552HhvAoE%hm#jfB2`JWq^F9a5TXZoH z^0I_1T~_EpZ$`r^bHC_1*tkiOJX&A5Oe^$ZbnSvaB(3-s-E0{DS&t@8zf&OC0EYeu zPBu1RDRfcMK*rNY$TE)}efS}kx!;*=ADy@(BRA#plNh54l>lVgs>RVhu*|t{Vd!47@f27){bH< zzG?RM4`Q_YEC%N?Z3g>!jwdcm!H5ow>{)@LCKXtIgf3b`hMq(S^SMDEtD%>925mo{0y2i=pxpus-6Uh>fc{MH~&nT`?s|uu33W7)L`f*1aa(98Vkp8Up40 zyD=4c3Wk{}aMm9|vl+TL2O*H&<( zf>I(vQ3l>$dg z%ptHIol=PTvV^qLn&nl=ebw>|bkV$NPh7G9!+TId0@7v+UaHW=1c-V6f-~^BMo-F? zbKHqiwsh@hmF&b+s6>HMWFYMtT!fR1+ydfGYnG>r$LqHqx+SZ`1Aj{#)^D#`VxATdcVe@=?6J7tgd)Y2Rsnxc+=mISTIAk(kY+F7RVg}I4Jm57hP)G- z<;_cbzd9!&+Y}mme@z_T7woD7rv^zqcwB@E5TkZ8$UC)J-s--D!qdg3#z9Bo{PT5- z+{YXw{{UVV(Mf5DQJ=>+i`W?SrtJNSI7i3a;fIdIVH&PmcCU002ovPDHLkV1lwj5@rAZ literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_webhook/static/description/icon.png b/addons/cetmix_tower_webhook/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2507f553896c442455b02ed5fa06b72ab398a990 GIT binary patch literal 22128 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 F{{#JqH_ZS5 literal 0 HcmV?d00001 diff --git a/addons/cetmix_tower_webhook/static/description/index.html b/addons/cetmix_tower_webhook/static/description/index.html new file mode 100644 index 0000000..ce4a897 --- /dev/null +++ b/addons/cetmix_tower_webhook/static/description/index.html @@ -0,0 +1,535 @@ + + + + + +Cetmix Tower Webhook + + + +
    +

    Cetmix Tower Webhook

    + + +

    Beta License: AGPL-3 cetmix/cetmix-tower

    +

    This module implements incoming webhooks for Cetmix +Tower. Webhooks are authorised using +customisable authenticators which can be pre-configured and reused +across multiple webhooks. Webhooks and authenticators can be exported +and imported using YAML format, which makes them easily sharable.

    +

    This module is a part of Cetmix Tower, however it can be used to manage +any other odoo applications.

    +

    Please refer to the official +documentation for detailed information.

    +

    Table of contents

    + +
    +

    Use Cases / Context

    +

    Although Odoo has native support of webhooks staring 17.0, they still +have some limitations. Another option is the OCA ‘endpoint’ module which +although is more flexible still makes it usable with Cetmix Tower more +complicated.

    +
    +
    +

    Configuration

    +
    +

    Configure an Authenticator

    +

    ⚠️ WARNING: You must be a member of the “Cetmix Tower/Root” group to +configure authenticators.

    +
      +
    • Go to “Cetmix Tower > Settings > Automation > Webhook Authenticators” +and click “New”.
    • +
    +

    Complete the following fields:

    +
      +
    • Name. Authenticator name
    • +
    • Reference. Unique reference. Leave this field blank to auto generate +it
    • +
    • Code. Code that is used to authenticate the request. You can use all +Cetmix Tower - Python command variables except for the server​ plus the +following webhook specific ones:
    • +
    • headers: dictionary that contains the request headers
    • +
    • raw_data: string with the raw HTTP request body
    • +
    • payload: dictionary that contains the JSON payload or the GET +parameters of the request
    • +
    +

    The code returns the result​ variable in the following format:

    +
    +result = {"allowed": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
    +
    +

    eg:

    +
    +result = {"allowed": True}
    +result = {"allowed": False, "http_code": 403, "message": "Sorry..."}
    +
    +
    +
    +

    Configure a Webhook

    +

    ⚠️ WARNING: You must be a member of the “Cetmix Tower/Root” group to +configure webhooks.

    +
      +
    • Go to “Cetmix Tower > Settings > Automation > Webhooks” and click +“New”.
    • +
    +

    Complete the following fields:

    +
      +
    • Enabled. Uncheck this field to disable the webhook without deleting it
    • +
    • Name. Authenticator name
    • +
    • Reference. Unique reference. Leave this field blank to auto generate +it
    • +
    • Authenticator. Select an Authenticator used for this webhook
    • +
    • Endpoint. Webhook endpoint. The complete webhook URL will be +<your_tower_url>/cetmix_tower_webhooks/​
    • +
    • Run as User. Select a user to run the webhook on behalf of. CAREFUL! +You must realize and understand what you are doing, including all the +possible consequences when selecting a specific user.
    • +
    • Code. Code that processes the request. You can use all Cetmix Tower +Python command variables (except for the server) plus the following +webhook-specific one:
        +
      • headers: dictionary that contains the request headers
      • +
      • payload: dictionary that contains the JSON payload or the GET +parameters of the request
      • +
      +
    • +
    +

    Webhook code returns a result using the Cetmix Tower Python command +pattern:

    +
    +result = {"exit_code": <int, default=0>, "message": <string, default=None>}
    +
    +

    To configure the time for which the webhook call logs are stored:

    +
      +
    • Go to “Cetmix Tower > Settings > General Settings”
    • +
    • Put a number of days into the “Keep Webhook Logs for (days)” field. +Default value is 30.
    • +
    +

    Please refer to the official +documentation for detailed configuration +instructions.

    +
    +
    +
    +

    Usage

    +

    When a request is received, Cetmix Tower will search for the webhook +with the matching endpoint and authenticate the request using the +selected authenticator. In case of successful authentication webhook +code is run. Each webhook call is logged. Logs are available under the +“Cetmix Tower > Logs > Webhook Calls” menu or under the “Logs” button +directly in the Webhook.

    +

    Please refer to the official +documentation for detailed usage +instructions.

    +
    +
    +

    Changelog

    +
    +

    18.0.1.0.1 (2025-12-17)

    +
      +
    • Features: Improve search views, implement the search panel for +selected views. (5139)
    • +
    +
    +
    +
    +

    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

    +
      +
    • Cetmix
    • +
    +
    +
    +

    Maintainers

    +

    This module is part of the cetmix/cetmix-tower project on GitHub.

    +

    You are welcome to contribute.

    +
    +
    +
    + + diff --git a/addons/cetmix_tower_webhook/tests/__init__.py b/addons/cetmix_tower_webhook/tests/__init__.py new file mode 100644 index 0000000..bf6fd5f --- /dev/null +++ b/addons/cetmix_tower_webhook/tests/__init__.py @@ -0,0 +1,7 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_cx_tower_webhook_authenticator +from . import test_cx_tower_webhook_log +from . import test_cx_tower_webhook +from . import test_webhook_controller diff --git a/addons/cetmix_tower_webhook/tests/common.py b/addons/cetmix_tower_webhook/tests/common.py new file mode 100644 index 0000000..673c0a4 --- /dev/null +++ b/addons/cetmix_tower_webhook/tests/common.py @@ -0,0 +1,38 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import TransactionCase + + +class CetmixTowerWebhookCommon(TransactionCase): + def setUp(self): + super().setUp() + + # Set base url for correct link generation + self.web_base_url = "https://example.com" + self.env["ir.config_parameter"].sudo().set_param( + "web.base.url", self.web_base_url + ) + + # Create simple authenticator that allows all requests + self.WebhookAuthenticator = self.env["cx.tower.webhook.authenticator"] + self.simple_authenticator = self.WebhookAuthenticator.create( + { + "name": "Simple Authenticator", + "code": "result = {'allowed': True, 'message': 'OK'}", + } + ) + + # Create Simple Webhook + self.Webhook = self.env["cx.tower.webhook"] + self.simple_webhook = self.Webhook.create( + { + "name": "Simple Webhook", + "endpoint": "simple_webhook", + "code": "result = {'exit_code': 0, 'message': 'OK'}", + "authenticator_id": self.simple_authenticator.id, + } + ) + + # Log model + self.Log = self.env["cx.tower.webhook.log"] diff --git a/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook.py b/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook.py new file mode 100644 index 0000000..e044686 --- /dev/null +++ b/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook.py @@ -0,0 +1,154 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError + +from .common import CetmixTowerWebhookCommon + + +class TestCetmixTowerWebhook(CetmixTowerWebhookCommon): + def test_simple_webhook_success(self): + """ + Test that webhook is successful + """ + result = self.simple_webhook.execute( + headers={}, payload={}, raw_data="", raise_on_error=False + ) + self.assertEqual(result["exit_code"], 0) + + def test_simple_webhook_without_optional_params(self): + """ + Test that webhook is successful without optional params + """ + result = self.simple_webhook.execute(raise_on_error=False) + self.assertEqual(result["exit_code"], 0) + + def test_webhook_code_custom_message(self): + """ + Test that custom message is returned from webhook code + """ + self.simple_webhook.write( + {"code": "result = {'exit_code': 0, 'message': 'Webhook OK!'}"} + ) + result = self.simple_webhook.execute(raise_on_error=False) + self.assertEqual(result["exit_code"], 0) + self.assertEqual(result["message"], "Webhook OK!") + + def test_webhook_code_failure(self): + """ + Test that webhook returns error when code sets exit_code != 0 + """ + self.simple_webhook.write( + {"code": "result = {'exit_code': 42, 'message': 'Error occurred'}"} + ) + result = self.simple_webhook.execute(raise_on_error=False) + self.assertEqual(result["exit_code"], 42) + self.assertEqual(result["message"], "Error occurred") + + def test_webhook_code_raises_exception(self): + """ + Test that exception in webhook code is handled and returns exit_code 1 + """ + self.simple_webhook.write({"code": "raise Exception('Webhook boom!')"}) + result = self.simple_webhook.execute(raise_on_error=False) + self.assertEqual(result["exit_code"], 1) + self.assertIn("Webhook boom!", result["message"]) + + def test_webhook_code_returns_non_dict(self): + """ + Test that webhook fails gracefully if code returns non-dict + """ + self.simple_webhook.write({"code": "result = 'not a dict'"}) + result = self.simple_webhook.execute(raise_on_error=False) + self.assertEqual(result["exit_code"], 1) + self.assertEqual( + result["message"], "Webhook/Authenticator code error: result is not a dict" + ) + + def test_webhook_execute_raises_exception(self): + """ + Test that webhook raises ValidationError if raise_on_error is True + """ + self.simple_webhook.write({"code": "raise Exception('Validation failed!')"}) + with self.assertRaises(ValidationError): + self.simple_webhook.execute(raise_on_error=True) + + def test_webhook_execute_with_payload(self): + """ + Test that webhook receives and processes payload correctly + """ + self.simple_webhook.write( + { + "code": "result = {'exit_code': 0, 'message': str(payload.get('key', 'none'))}" # noqa: E501 + } + ) + payload = {"key": "value123"} + result = self.simple_webhook.execute(payload=payload, raise_on_error=False) + self.assertEqual(result["exit_code"], 0) + self.assertEqual(result["message"], "value123") + + def test_webhook_execute_with_user(self): + """ + Test that webhook executes as specified user + """ + test_user = self.env.ref("base.user_demo") + self.simple_webhook.user_id = test_user + self.simple_webhook.write( + {"code": "result = {'exit_code': 0, 'message': user.login}"} + ) + result = self.simple_webhook.execute(raise_on_error=False) + self.assertEqual(result["message"], test_user.login) + + def test_webhook_context_isolation(self): + """ + Test that only payload is available in eval context; + extra kwargs are not accessible + """ + self.simple_webhook.write( + { + "code": ( + "fail = []\n" + "for var in ['headers', 'raw_data', 'custom_param']:\n" + " try:\n" + " _ = eval(var)\n" + " fail.append(var)\n" + " except Exception:\n" + " pass\n" + "if fail:\n" + " result = {'exit_code': 99, 'message': 'Leaked vars: ' + ','.join(fail)}\n" # noqa: E501 + "else:\n" + " result = {'exit_code': 0, 'message': 'Context clean'}\n" + ) + } + ) + result = self.simple_webhook.execute( + payload={"key": "val"}, + headers={"x": "y"}, + raw_data="boom", + custom_param="xxx", + raise_on_error=False, + ) + self.assertEqual(result["exit_code"], 0, result["message"]) + self.assertIn("Context clean", result["message"]) + + def test_webhook_execute_runs_as_user_id(self): + """ + Test that the webhook code is always executed as the specified user_id, + regardless of the caller's user context or extra kwargs. + """ + # set specific user + test_user = self.env.ref("base.user_demo") + self.simple_webhook.user_id = test_user + self.simple_webhook.write( + {"code": "result = {'exit_code': 0, 'message': user.login}"} + ) + + # run execute() with another user and try to pass user_id via kwargs + other_user = self.env.ref("base.user_admin") + result = self.simple_webhook.with_user(other_user).execute( + payload={}, + user_id=self.env.ref("base.user_root").id, # try to pass own user_id + raise_on_error=False, + ) + # the result should be from user_demo anyway + self.assertEqual(result["message"], test_user.login) diff --git a/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook_authenticator.py b/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook_authenticator.py new file mode 100644 index 0000000..d714db6 --- /dev/null +++ b/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook_authenticator.py @@ -0,0 +1,143 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError + +from .common import CetmixTowerWebhookCommon + + +class TestCetmixTowerWebhookAuthenticator(CetmixTowerWebhookCommon): + def test_simple_authentication_success(self): + """ + Test that authentication is successful + """ + # check that authentication is successful for authenticator + # that allows all requests + result = self.simple_authenticator.authenticate( + headers={}, payload={}, raw_data="" + ) + self.assertTrue(result["allowed"]) + + def test_simple_authentication_without_optional_params(self): + """ + Test that authentication is successful without optional params + """ + result = self.simple_authenticator.authenticate() + self.assertTrue(result["allowed"]) + + def test_token_authentication_success(self): + """ + Test that authentication is successful for authenticator that allows requests + with specific token in header + """ + auth_token_header = "X-Token" + auth_token = "secret123" + code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501 + self.simple_authenticator.code = code + result = self.simple_authenticator.authenticate( + headers={auth_token_header: auth_token} + ) + self.assertTrue(result["allowed"]) + + def test_token_authentication_failure(self): + """ + Test that authentication is failed for authenticator that allows + requests with specific token in header + """ + auth_token_header = "X-Token" + auth_token = "secret123" + code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501 + self.simple_authenticator.code = code + result = self.simple_authenticator.authenticate( + headers={auth_token_header: "wrong_token"}, raise_on_error=False + ) + self.assertFalse(result["allowed"]) + + def test_token_authentication_failure_without_optional_params(self): + """ + Test that authentication is failed without optional params + """ + auth_token_header = "X-Token" + auth_token = "secret123" + code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501 + self.simple_authenticator.code = code + result = self.simple_authenticator.authenticate(raise_on_error=False) + self.assertFalse(result["allowed"]) + self.assertEqual(result["http_code"], 500) + self.assertIn("object has no attribute 'get'", result["message"]) + + def test_authentication_code_error(self): + """ + Test that authentication is failed with invalid code + """ + self.simple_authenticator.code = "1/0" + result = self.simple_authenticator.authenticate(raise_on_error=False) + self.assertFalse(result["allowed"]) + self.assertEqual(result["http_code"], 500) + self.assertEqual(result["message"], "division by zero") + + # test with raise_on_error=True + with self.assertRaises(ValidationError) as e: + self.simple_authenticator.authenticate() + self.assertEqual( + str(e.exception), "Authentication code error: division by zero" + ) + + def test_authenticator_custom_http_code_and_message(self): + """ + Test that custom http_code and message returned from code are respected + """ + message = "I am a teapot!" + self.simple_authenticator.code = ( + f"result = {{'allowed': False, 'http_code': 418, 'message': '{message}'}}" + ) + result = self.simple_authenticator.authenticate(headers={}) + self.assertFalse(result["allowed"]) + self.assertEqual(result.get("http_code"), 418) + self.assertEqual(result.get("message"), message) + + def test_authenticator_returns_non_dict(self): + """ + Test that authentication fails if code returns non-dict result + """ + self.simple_authenticator.write({"code": "result = 'not a dict'"}) + result = self.simple_authenticator.authenticate( + headers={}, raise_on_error=False + ) + self.assertFalse(result["allowed"]) + self.assertEqual(result["http_code"], 500) + self.assertIn("result is not a dict", result["message"]) + + def test_authentication_with_raw_data(self): + """ + Test that authentication works with raw_data and without headers + """ + self.simple_authenticator.write( + {"code": "result = {'allowed': raw_data == 'magic'}"} + ) + result = self.simple_authenticator.authenticate(raw_data="magic") + self.assertTrue(result["allowed"]) + result = self.simple_authenticator.authenticate(raw_data="not_magic") + self.assertFalse(result["allowed"]) + + def test_authentication_code_exception(self): + """ + Test that authentication code exception is captured in result['message'] + """ + self.simple_authenticator.write({"code": "raise Exception('custom failure')"}) + result = self.simple_authenticator.authenticate( + headers={}, raise_on_error=False + ) + self.assertFalse(result["allowed"]) + self.assertEqual(result["http_code"], 500) + self.assertIn("custom failure", result["message"]) + + def test_authentication_minimal_false(self): + """ + Test minimal code with only allowed: False + """ + self.simple_authenticator.write({"code": "result = {'allowed': False}"}) + result = self.simple_authenticator.authenticate(headers={}) + self.assertFalse(result["allowed"]) + self.assertIsNone(result.get("http_code")) + self.assertIsNone(result.get("message")) diff --git a/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook_log.py b/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook_log.py new file mode 100644 index 0000000..c51cbfe --- /dev/null +++ b/addons/cetmix_tower_webhook/tests/test_cx_tower_webhook_log.py @@ -0,0 +1,68 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +from datetime import datetime, timedelta + +from .common import CetmixTowerWebhookCommon + + +class TestCetmixTowerWebhookLog(CetmixTowerWebhookCommon): + def test_create_log_from_call(self): + """Test creating a log entry via create_from_call().""" + vals = { + "result_message": "Manual log", + "http_status": 201, + "authentication_status": "success", + "code_status": "success", + "request_payload": json.dumps({"foo": "bar"}), + "request_headers": json.dumps({"X-Test": "test"}), + "webhook_id": self.simple_webhook.id, + } + log = self.Log.create_from_call(webhook=self.simple_webhook, **vals) + self.assertEqual(log.webhook_id, self.simple_webhook) + self.assertEqual(log.result_message, "Manual log") + self.assertEqual(log.http_status, 201) + self.assertEqual(log.authentication_status, "success") + self.assertIn("foo", log.request_payload) + self.assertIn("X-Test", log.request_headers) + + def test_gc_delete_old_logs(self): + """Test auto-removal of old logs via _gc_delete_old_logs().""" + # Create an "old" log + old_log = self.Log.create_from_call( + webhook=self.simple_webhook, + authentication_status="success", + code_status="success", + http_status=200, + ) + # Set create_date in the past (we cannot use write + # because the create_date is MAGIC Field) + past_date = (datetime.now() - timedelta(days=100)).strftime("%Y-%m-%d %H:%M:%S") + self.env.cr.execute( + "UPDATE cx_tower_webhook_log SET create_date = %s WHERE id = %s", + (past_date, old_log.id), + ) + self.env.invalidate_all() + # Create a new log + new_log = self.Log.create_from_call( + webhook=self.simple_webhook, + authentication_status="success", + code_status="success", + http_status=200, + ) + # Set log duration to 30 days + self.env["ir.config_parameter"].sudo().set_param( + "cetmix_tower_webhook.webhook_log_duration", 30 + ) + # Enter test mode to run the autovacuum cron because + # `_run_vacuum_cleaner` makes a commit + self.registry.enter_test_mode(self.cr) + self.addCleanup(self.registry.leave_test_mode) + env = self.env(cr=self.registry.cursor()) + + # Run the autovacuum cron + env.ref("base.autovacuum_job").method_direct_trigger() + + self.assertFalse(self.Log.browse(old_log.id).exists()) + self.assertTrue(self.Log.browse(new_log.id).exists()) diff --git a/addons/cetmix_tower_webhook/tests/test_webhook_controller.py b/addons/cetmix_tower_webhook/tests/test_webhook_controller.py new file mode 100644 index 0000000..6324ef2 --- /dev/null +++ b/addons/cetmix_tower_webhook/tests/test_webhook_controller.py @@ -0,0 +1,608 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +from unittest.mock import patch + +from odoo.tests import HttpCase, tagged + + +@tagged("-at_install", "post_install") +class TestCxTowerWebhookController(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + env = cls.env + # Authenticator that always allows requests + cls.authenticator = env["cx.tower.webhook.authenticator"].create( + {"name": "Always OK", "code": "result = {'allowed': True}"} + ) + # POST webhook + cls.webhook_post = env["cx.tower.webhook"].create( + { + "name": "Test Webhook POST", + "endpoint": "webhook_post", + "method": "post", + "authenticator_id": cls.authenticator.id, + "code": "result = {'exit_code': 0, 'message': 'POST ok'}", + } + ) + # GET webhook + cls.webhook_get = env["cx.tower.webhook"].create( + { + "name": "Test Webhook GET", + "endpoint": "webhook_get", + "method": "get", + "authenticator_id": cls.authenticator.id, + "code": "result = {'exit_code': 0, 'message': 'GET ok'}", + } + ) + # Log model + cls.Log = env["cx.tower.webhook.log"] + + def url_for(self, endpoint): + """Helper to build webhook url""" + url = f"/cetmix_tower_webhooks/{endpoint}" + return self.base_url() + url + + def assert_log(self, log=None, request_payload=None, **expected): + """ + Universal log checker for webhook log model. + Checks expected field values and substrings. + """ + self.assertIsNotNone(log, "Log record was not created") + if request_payload is not None: + try: + log_payload = log.request_payload + # try to convert both to Python dict for comparison + if isinstance(log_payload, str): + log_payload = log_payload.strip() + self.assertDictEqual( + json.loads( + log_payload.replace("'", '"') + ), # try to make JSON from possible str(dict) + json.loads(request_payload), + ) + except Exception as ex: + self.fail( + f"Payload comparison failed: {ex}\nLog: {log.request_payload}\nExpected: {request_payload}" # noqa: E501 + ) + for field, value in expected.items(): + if field == "request_payload": + continue # Already checked + actual = getattr(log, field) + self.assertEqual(actual, value, f"{field}: expected {value}, got {actual}") + + def test_post_webhook_success(self): + """Success test for POST request with correct payload.""" + data = json.dumps({"some": "data"}) + response = self.url_open( + self.url_for(self.webhook_post.endpoint), + data=data, + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn(b"POST ok", response.content) + + log = self.Log.search([("webhook_id", "=", self.webhook_post.id)]) + self.assert_log( + log, + code_status="success", + authentication_status="success", + http_status=200, + endpoint=self.webhook_post.endpoint, + request_payload=data, + ) + + def test_get_webhook_success(self): + """Success test for GET request with correct payload.""" + response = self.url_open( + f"{self.url_for(self.webhook_get.endpoint)}?foo=bar", + ) + self.assertEqual(response.status_code, 200) + self.assertIn(b"GET ok", response.content) + + log = self.Log.search([("webhook_id", "=", self.webhook_get.id)]) + self.assert_log( + log, + code_status="success", + authentication_status="success", + http_status=200, + endpoint=self.webhook_get.endpoint, + ) + self.assertIn("foo", log.request_payload) + + def test_webhook_not_found(self): + """Test request to a non-existing webhook endpoint.""" + data = json.dumps({"test": 1}) + response = self.url_open( + self.url_for("missing"), + data=data, + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 404) + self.assertIn(b"Webhook not found", response.content) + + log = self.Log.search([("webhook_id", "=", False)]) + self.assert_log( + log, + code_status="skipped", + authentication_status="failed", + http_status=404, + endpoint="missing", + error_message="Webhook not found", + request_payload=data, + ) + + def test_wrong_method(self): + """ + Test GET request to POST-only webhook. + """ + response = self.url_open( + self.url_for(self.webhook_post.endpoint), + ) + self.assertEqual(response.status_code, 404) + self.assertIn(b"Webhook not found", response.content) + + log = self.Log.search([("webhook_id", "=", False)]) + self.assert_log( + log, + code_status="skipped", + authentication_status="failed", + http_status=404, + error_message="Webhook not found", + endpoint=self.webhook_post.endpoint, + request_method="get", + ) + + def test_missing_payload_post(self): + """ + Test POST request with empty payload. + """ + # use opener instead of url_open to avoid checking of data + response = self.opener.post( + self.url_for(self.webhook_post.endpoint), + timeout=1200000, + headers={"Content-Type": "application/json"}, + allow_redirects=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn(b"POST ok", response.content) + + log = self.Log.search([("webhook_id", "=", self.webhook_post.id)]) + self.assert_log( + log, + code_status="success", + authentication_status="success", + http_status=200, + endpoint=self.webhook_post.endpoint, + request_payload="{}", + ) + + def test_authentication_failed(self): + """ + Test POST request with authenticator that always denies. + """ + bad_auth = self.env["cx.tower.webhook.authenticator"].create( + { + "name": "Never OK", + "code": "result = {'allowed': False, 'custom_message': 'Forbidden'}", + } + ) + webhook = self.env["cx.tower.webhook"].create( + { + "name": "Forbidden Webhook", + "endpoint": "forbidden", + "method": "post", + "authenticator_id": bad_auth.id, + "code": "result = {'exit_code': 0, 'message': 'Should not run'}", + } + ) + data = json.dumps({"fail": 1}) + response = self.url_open( + self.url_for(webhook.endpoint), + data=data, + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 403) + self.assertIn(b"Authentication not allowed", response.content) + + log = self.Log.search([("webhook_id", "=", webhook.id)]) + self.assert_log( + log, + code_status="skipped", + authentication_status="failed", + http_status=403, + endpoint=webhook.endpoint, + request_payload=data, + ) + + def test_webhook_code_failure(self): + """ + Test POST request to a webhook that raises an exception in code. + """ + self.webhook_post.code = "raise Exception('Some error!')" + response = self.url_open( + self.url_for(self.webhook_post.endpoint), + data=json.dumps({}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 500) + self.assertIn(b"Some error!", response.content) + + log = self.Log.search([("webhook_id", "=", self.webhook_post.id)]) + self.assert_log( + log, + code_status="failed", + authentication_status="success", + http_status=500, + endpoint=self.webhook_post.endpoint, + request_payload="{}", + ) + self.assertIn("Some error!", log.error_message) + + def test_json_headers_are_stored(self): + """ + Test that request headers and payload are saved in webhook log record. + """ + payload = {"foo": "bar"} + headers = {"X-Test-Header": "xxx", "Content-Type": "application/json"} + response = self.url_open( + self.url_for(self.webhook_post.endpoint), + data=json.dumps(payload), + headers=headers, + ) + + log = self.Log.search([("webhook_id", "=", self.webhook_post.id)]) + self.assert_log( + log, + code_status="success", + authentication_status="success", + http_status=200, + endpoint=self.webhook_post.endpoint, + ) + self.assertIn("foo", log.request_payload) + self.assertIn("X-Test-Header", log.request_headers) + self.assertIn(log.result_message, response.text) + + def test_log_contains_ip(self): + """ + Test that the log contains the client's IP address and country (if available). + """ + payload = {"check": "ip"} + self.url_open( + self.url_for(self.webhook_post.endpoint), + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + + log = self.Log.search([("webhook_id", "=", self.webhook_post.id)]) + self.assertTrue(log.ip_address) + + def test_inactive_webhook(self): + """Test that inactive webhooks are not callable.""" + self.webhook_post.active = False + response = self.url_open( + self.url_for(self.webhook_post.endpoint), + data=json.dumps({"a": 1}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 404) + self.assertIn(b"Webhook not found", response.content) + + def test_authenticator_code_raises(self): + """ + Test that if authenticator's code raises an error, + proper log is created and 403 returned. + """ + bad_auth = self.env["cx.tower.webhook.authenticator"].create( + {"name": "Broken Auth", "code": "raise Exception('auth fail')"} + ) + webhook = self.env["cx.tower.webhook"].create( + { + "name": "Web with bad auth", + "endpoint": "bad_auth", + "method": "post", + "authenticator_id": bad_auth.id, + "code": "result = {'exit_code': 0, 'message': 'Should not run'}", + } + ) + response = self.url_open( + self.url_for(webhook.endpoint), + data=json.dumps({"x": 1}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 403) + self.assertIn(b"auth fail", response.content) + + log = self.Log.search([("webhook_id", "=", webhook.id)]) + self.assert_log( + log, + code_status="skipped", + authentication_status="failed", + http_status=403, + endpoint=webhook.endpoint, + ) + self.assertIn("auth fail", log.error_message) + + def test_post_webhook_json_content_type(self): + """ + Test POST request with content_type json. + """ + self.webhook_post.content_type = "json" + self.webhook_post.code = "result = {'exit_code': 0, 'message': 'POST JSON ok'}" + + data = json.dumps({"json_test": "ok"}) + response = self.url_open( + self.url_for(self.webhook_post.endpoint), + data=data, + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn(b"POST JSON ok", response.content) + + log = self.Log.search([("webhook_id", "=", self.webhook_post.id)]) + self.assert_log( + log, + code_status="success", + authentication_status="success", + http_status=200, + endpoint=self.webhook_post.endpoint, + request_payload=data, + ) + + def test_post_webhook_form_content_type(self): + """ + Test POST request with content_type form. + """ + self.webhook_post.content_type = "form" + self.webhook_post.code = "result = {'exit_code': 0, 'message': 'POST FORM ok'}" + + data = {"form_field": "ok"} + response = self.url_open( + self.url_for(self.webhook_post.endpoint), + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn(b"POST FORM ok", response.content) + + log = self.Log.search([("webhook_id", "=", self.webhook_post.id)]) + self.assertIn("form_field", log.request_payload) + + def test_authenticator_ipv4_and_ipv6(self): + """ + Test IP filter for IPv4, IPv6, and networks + by monkeypatching REMOTE_ADDR in environ. + """ + auth = self.env["cx.tower.webhook.authenticator"].create( + { + "name": "IP Test", + "allowed_ip_addresses": "203.0.113.5,2001:db8::42,198.51.100.0/24,2001:db8:abcd::/48", # noqa: E501 + "code": "result = {'allowed': True}", + } + ) + webhook = self.env["cx.tower.webhook"].create( + { + "name": "IP Webhook", + "endpoint": "webhook_iptest", + "method": "post", + "authenticator_id": auth.id, + "code": "result = {'exit_code': 0, 'message': 'IP OK'}", + } + ) + + data = json.dumps({"ip": "test"}) + + def do_req(ip): + # Patch _get_remote_addr to simulate requests coming + # from different IP addresses + with patch( + "odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr", + return_value=ip, + ): + return self.url_open( + self.url_for(webhook.endpoint), + data=data, + headers={"Content-Type": "application/json"}, + ) + + # IPv4 address allowed + resp = do_req("203.0.113.5") + self.assertEqual(resp.status_code, 200) + self.assertIn(b"IP OK", resp.content) + + # IPv6 address allowed + resp = do_req("2001:db8::42") + self.assertEqual(resp.status_code, 200) + self.assertIn(b"IP OK", resp.content) + + # IPv4 network allowed + resp = do_req("198.51.100.99") + self.assertEqual(resp.status_code, 200) + self.assertIn(b"IP OK", resp.content) + + # IPv6 network allowed + resp = do_req("2001:db8:abcd::abcd") + self.assertEqual(resp.status_code, 200) + self.assertIn(b"IP OK", resp.content) + + # Denied IPv4 address + resp = do_req("203.0.113.99") + self.assertEqual(resp.status_code, 403) + self.assertIn(b"Address not allowed", resp.content) + + # Denied IPv6 address + resp = do_req("2001:db8:ffff::1") + self.assertEqual(resp.status_code, 403) + self.assertIn(b"Address not allowed", resp.content) + + def _make_proxy_webhook( + self, + allowed, + trusted=None, + code="result = {'exit_code': 0, 'message': 'OK via proxy'}", + ): + """ + Helper to create a webhook with a dedicated authenticator configured + for proxy-aware tests. + """ + auth = self.env["cx.tower.webhook.authenticator"].create( + { + "name": "Proxy Aware", + "allowed_ip_addresses": allowed, + "trusted_proxy_ips": trusted or "", + "code": "result = {'allowed': True}", + } + ) + wh = self.env["cx.tower.webhook"].create( + { + "name": "Proxy Webhook", + "endpoint": "proxy_webhook", + "method": "post", + "authenticator_id": auth.id, + "code": code, + } + ) + return wh, auth + + def test_proxy_headers_ignored_without_trusted_proxy(self): + """ + When trusted_proxy_ips is empty, XFF/X-Real-IP must be ignored. + We fallback to immediate peer (proxy IP), which is not allowed -> 403. + """ + # Allow only the real client network, not the proxy itself + webhook, _auth = self._make_proxy_webhook( + allowed="203.0.113.0/24", trusted=None + ) + + data = json.dumps({"k": "v"}) + proxy_ip = "10.0.0.5" # immediate peer (undocumented as trusted) + headers = { + "Content-Type": "application/json", + "X-Forwarded-For": "203.0.113.7, 10.0.0.5", # should be ignored + "X-Real-IP": "203.0.113.7", # should be ignored + } + with patch( + "odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr", + return_value=proxy_ip, + ): + resp = self.url_open( + self.url_for(webhook.endpoint), data=data, headers=headers + ) + + self.assertEqual(resp.status_code, 403) + self.assertIn(b"Address not allowed", resp.content) + + def test_proxy_xff_honored_with_trusted_proxy(self): + """ + With trusted proxy configured, take the left-most IP from X-Forwarded-For. + """ + webhook, _auth = self._make_proxy_webhook( + allowed="203.0.113.0/24", + trusted="10.0.0.5", + code="result = {'exit_code': 0, 'message': 'OK XFF'}", + ) + + data = json.dumps({"k": "v"}) + proxy_ip = "10.0.0.5" + headers = { + "Content-Type": "application/json", + # XFF list: client, proxy + "X-Forwarded-For": "203.0.113.7, 10.0.0.5", + } + with patch( + "odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr", + return_value=proxy_ip, + ): + resp = self.url_open( + self.url_for(webhook.endpoint), data=data, headers=headers + ) + + self.assertEqual(resp.status_code, 200) + self.assertIn(b"OK XFF", resp.content) + + def test_proxy_x_real_ip_fallback_when_xff_missing(self): + """ + If XFF is missing/invalid but trusted proxy is set, fall back to X-Real-IP. + """ + webhook, _auth = self._make_proxy_webhook( + allowed="203.0.113.0/24", + trusted="10.0.0.5", + code="result = {'exit_code': 0, 'message': 'OK X-Real-IP'}", + ) + + data = json.dumps({"k": "v"}) + proxy_ip = "10.0.0.5" + headers = { + "Content-Type": "application/json", + "X-Forwarded-For": "garbage, not_an_ip", # invalids should be skipped + "X-Real-IP": "203.0.113.8", + } + with patch( + "odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr", + return_value=proxy_ip, + ): + resp = self.url_open( + self.url_for(webhook.endpoint), data=data, headers=headers + ) + + self.assertEqual(resp.status_code, 200) + self.assertIn(b"OK X-Real-IP", resp.content) + + def test_proxy_invalid_headers_fall_back_to_immediate_peer(self): + """ + If headers are invalid even with trusted proxy, fall back to immediate peer. + Since the proxy IP is not in allowlist, the request is denied. + """ + webhook, _auth = self._make_proxy_webhook( + allowed="203.0.113.0/24", # does NOT include proxy IP + trusted="10.0.0.5", + ) + + data = json.dumps({"k": "v"}) + proxy_ip = "10.0.0.5" + headers = { + "Content-Type": "application/json", + "X-Forwarded-For": "not_an_ip, also_bad", + "X-Real-IP": "bad_ip_value", + } + with patch( + "odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr", + return_value=proxy_ip, + ): + resp = self.url_open( + self.url_for(webhook.endpoint), data=data, headers=headers + ) + + self.assertEqual(resp.status_code, 403) + self.assertIn(b"Address not allowed", resp.content) + + def test_proxy_allows_via_immediate_peer_when_proxy_ip_in_allowlist(self): + """ + If headers are ignored/invalid, but the proxy IP itself is allowed, + access should be granted based on immediate peer. + """ + webhook, _auth = self._make_proxy_webhook( + allowed="10.0.0.5", # allow the proxy itself + trusted="", # no trusted proxies => headers ignored + code="result = {'exit_code': 0, 'message': 'OK immediate peer'}", + ) + + data = json.dumps({"k": "v"}) + proxy_ip = "10.0.0.5" + headers = { + "Content-Type": "application/json", + "X-Forwarded-For": "203.0.113.7", # should be ignored + } + with patch( + "odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr", + return_value=proxy_ip, + ): + resp = self.url_open( + self.url_for(webhook.endpoint), data=data, headers=headers + ) + + self.assertEqual(resp.status_code, 200) + self.assertIn(b"OK immediate peer", resp.content) diff --git a/addons/cetmix_tower_webhook/views/cx_tower_variable_views.xml b/addons/cetmix_tower_webhook/views/cx_tower_variable_views.xml new file mode 100644 index 0000000..17c473e --- /dev/null +++ b/addons/cetmix_tower_webhook/views/cx_tower_variable_views.xml @@ -0,0 +1,40 @@ + + + cx.tower.variable.view.form + cx.tower.variable + + + + + + + + + diff --git a/addons/cetmix_tower_webhook/views/cx_tower_webhook_authenticator_views.xml b/addons/cetmix_tower_webhook/views/cx_tower_webhook_authenticator_views.xml new file mode 100644 index 0000000..42c6a1e --- /dev/null +++ b/addons/cetmix_tower_webhook/views/cx_tower_webhook_authenticator_views.xml @@ -0,0 +1,127 @@ + + + + cx.tower.webhook.authenticator.view.form + cx.tower.webhook.authenticator + +
    + +
    + +
    + + + + + + + + + + + + + + + + + + + + +
    +

    You must be a member of the "YAML/Export" group to export data as YAML

    +
    + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+

You must be a member of the "YAML/Export" group to export data as YAML.

+
+