Tower: upload cetmix_tower_webhook 18.0.1.0.1 (was 18.0.1.0.1, via marketplace)

This commit is contained in:
2026-05-03 18:54:56 +00:00
parent ee7e3fb398
commit bf36bd383a
39 changed files with 5393 additions and 0 deletions

View File

@@ -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

View File

@@ -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": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
# default value is {"allowed": False}
"""
# Default Python code help used in Webhook Authenticator
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP = """
<h3>Help for Webhook Authenticator Python Code</h3>
<div style="margin-bottom: 10px;">
<p>
The Python code for the webhook authenticator must return the <code>result</code> variable, which is a dictionary.<br>
<strong>Allowed keys:</strong>
<ul>
<li><code>allowed</code> (<b>bool</b>, required): Authentication result. <code>True</code> if allowed, <code>False</code> otherwise.</li>
<li><code>http_code</code> (<b>int</b>, optional): HTTP status code to return if authentication fails (default is 403).</li>
<li><code>message</code> (<b>str</b>, optional): Error message to show to the client.</li>
</ul>
<strong>Examples:</strong>
<pre style='background:#f7f7f7; padding:6px; border-radius:4px'>
# Allow all requests
result = {"allowed": True}
# Deny with custom code and message
result = {"allowed": False, "http_code": 401, "message": "Unauthorized request"}
</pre>
</p>
<strong>Available variables:</strong>
</div>
"""
# 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": <int, default=0>, "message": <string, default=None>}
# default value is {"exit_code": 0, "message": None}
"""
# Default Python code help used in Webhook
DEFAULT_WEBHOOK_CODE_HELP = """
<h3>Help for Webhook Python Code</h3>
<div style="margin-bottom: 10px;">
<p>
The webhook Python code must set the <code>result</code> variable, which is a dictionary.<br>
<strong>Allowed keys:</strong>
<ul>
<li><code>exit_code</code> (<b>int</b>, optional, default=0): Exit code (0 means success, other values indicate failure).</li>
<li><code>message</code> (<b>str</b>, optional): Message to return in the HTTP response and log.</li>
</ul>
<strong>Example:</strong>
<pre style='background:#f7f7f7; padding:6px; border-radius:4px'>
# Simple successful result
result = {"exit_code": 0, "message": "Webhook processed successfully"}
# Failure example
result = {"exit_code": 1, "message": "Something went wrong"}
</pre>
</p>
<strong>Available variables:</strong>
</div>
"""

View File

@@ -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

View File

@@ -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 "
"<your_tower_url>/cetmix_tower_webhooks/<endpoint>",
)
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': <int>,
'message': <str>
}
"""
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

View File

@@ -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": <bool>,
"http_code": <int, optional>,
"message": <str, optional>,
}
"""
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

View File

@@ -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"<li><code>{key}</code>: {value['help']}</li>")
help_text = "<ul>" + "".join(help_text_fragments) + "</ul>"
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

View File

@@ -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()

View File

@@ -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",
)