Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions setup/webhook_incoming/odoo/addons/webhook_incoming
6 changes: 6 additions & 0 deletions setup/webhook_incoming/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
1 change: 1 addition & 0 deletions test-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
odoo-addon-webhook_outgoing @ git+https://github.com/OCA/webhook@refs/pull/13/head#subdirectory=setup/webhook_outgoing
83 changes: 83 additions & 0 deletions webhook_incoming/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
================
Incoming Webhook
================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:801eb9069d1b38681a4da1eec8219ce59055f9cea46af867d49a7be05b955dc4
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwebhook-lightgray.png?logo=github
:target: https://github.com/OCA/webhook/tree/16.0/webhook_incoming
:alt: OCA/webhook
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/webhook-16-0/webhook-16-0-webhook_incoming
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/webhook&target_branch=16.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module allow creating an automation that send webhook/requests to another systems via HTTP.

To create a new automation to send webhook requests, go to Settings > Automated Actions:

* When add an automation, choose `Custom Webhook` as action to perform.
* Config Endpoint, Headers and Body Template accordingly.

This webhook action use Jinja and rendering engine, you can draft body template using Jinja syntax.

**Table of contents**

.. contents::
:local:

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/webhook/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 <https://github.com/OCA/webhook/issues/new?body=module:%20webhook_incoming%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
~~~~~~~

* Hoang Tran

Contributors
~~~~~~~~~~~~

* Hoang Tran <thhoang.tr@gmail.com>

Maintainers
~~~~~~~~~~~

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

This module is part of the `OCA/webhook <https://github.com/OCA/webhook/tree/16.0/webhook_incoming>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
2 changes: 2 additions & 0 deletions webhook_incoming/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import models
from . import controllers
16 changes: 16 additions & 0 deletions webhook_incoming/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright 2024 Hoang Tran <thhoang.tr@gmail.com>.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).

{
"name": "Incoming Webhook",
"summary": "Receive incoming webhook requests as trigger to execute tasks.",
"version": "16.0.0.0.1",
"author": "Hoang Tran,Odoo Community Association (OCA)",
"license": "LGPL-3",
"website": "https://github.com/OCA/webhook",
"depends": ["base_automation", "webhook_outgoing", "queue_job"],
"data": [
"views/base_automation_views.xml",
],
"auto_install": True,
}
1 change: 1 addition & 0 deletions webhook_incoming/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
40 changes: 40 additions & 0 deletions webhook_incoming/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright 2024 Hoang Tran <thhoang.tr@gmail.com>.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo.http import Controller, request, route


def get_webhook_request_payload():
if not request:
return None
try:
payload = request.get_json_data()
except ValueError:
payload = {**request.httprequest.args}
return payload


class BaseAutomationController(Controller):
@route(
["/web/hook/<string:rule_uuid>"],
type="http",
auth="public",
methods=["GET", "POST"],
csrf=False,
save_session=False,
)
def call_webhook_http(self, rule_uuid, **kwargs):
"""Execute an automation webhook"""
rule = (
request.env["base.automation"]
.sudo()
.search([("webhook_uuid", "=", rule_uuid)])
)
if not rule:
return request.make_json_response({"status": "error"}, status=404)

data = get_webhook_request_payload()
try:
rule._execute_webhook(data)
except Exception: # noqa: BLE001
return request.make_json_response({"status": "error"}, status=500)
return request.make_json_response({"status": "ok"}, status=200)
2 changes: 2 additions & 0 deletions webhook_incoming/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import base_automation
from . import ir_actions_server
234 changes: 234 additions & 0 deletions webhook_incoming/models/base_automation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# Copyright 2024 Hoang Tran <thhoang.tr@gmail.com>.
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import base64
import logging
import traceback
from uuid import uuid4

from pytz import timezone

from odoo import Command, _, api, exceptions, fields, models, tools
from odoo.tools import ustr
from odoo.tools.float_utils import float_compare
from odoo.tools.safe_eval import safe_eval

_logger = logging.getLogger(__name__)


class InheritedBaseAutomation(models.Model):
_inherit = "base.automation"

trigger = fields.Selection(
selection_add=[("on_webhook", "On webhook")], ondelete={"on_webhook": "cascade"}
)
webhook_uuid = fields.Char(
string="Webhook UUID",
readonly=True,
copy=False,
default=lambda self: str(uuid4()),
)
url = fields.Char(string="Webhook URL", compute="_compute_url")
log_webhook_calls = fields.Boolean(string="Log Calls", default=False)
allow_creation = fields.Boolean(
string="Allow creation?",
help="Allow executing webhook to maybe create record if a record is not "
"found using record getter",
)
record_getter = fields.Char(
default="model.env[payload.get('_model')].browse(int(payload.get('_id')))",
help="This code will be run to find on which record the automation rule should be run.",
)
create_record_code = fields.Text(
"Record Creation Code",
default="""# Available variables:
# - env: Odoo Environment on which the action is triggered
# - model: Odoo Model of the record on which the action is triggered;
# is a void recordset
# - record: record on which the action is triggered; may be void
# - records: recordset of all records on which the action is triggered
# in multi-mode; may be void
# - payload: input payload from webhook request
# - time, datetime, dateutil, timezone: useful Python libraries
# - float_compare: Odoo function to compare floats based on specific precisions
# - log: log(message, level='info'): logging function to record debug information
# in ir.logging table
# - UserError: Warning Exception to use with raise
# - Command: x2Many commands namespace
# You must return the created record by assign it to `record` variable:
# - record = res.partner(1,)
""",
help="This block of code is eval if Record Getter couldn't find a matching record.",
)
create_record_action_id = fields.Many2one(comodel_name="ir.actions.server")
delay_execution = fields.Boolean(
help="Queue actions to perform to delay execution."
)

@api.depends("webhook_uuid")
def _compute_webhook_url(self):
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
for webhook in self:
webhook.webhook_url = "%s/web/hook/%s" % (base_url, webhook.webhook_uuid)

@api.depends("trigger", "webhook_uuid")
def _compute_url(self):
for automation in self:
if automation.trigger != "on_webhook":
automation.url = ""
else:
automation.url = "%s/web/hook/%s" % (
automation.get_base_url(),
automation.webhook_uuid,
)

def _get_eval_context(self, payload=None):
"""
Override to add payload to context
"""
eval_context = super()._get_eval_context()
eval_context["model"] = self.env[self.model_name]
eval_context["payload"] = payload if payload is not None else {}
return eval_context

def _execute_webhook(self, payload):
"""Execute the webhook for the given payload.
The payload is a dictionnary that can be used by the `record_getter` to
identify the record on which the automation should be run.
"""
self.ensure_one()

# info logging is done by the ir.http logger
msg = "Webhook #%s triggered with payload %s"
msg_args = (self.id, payload)
_logger.debug(msg, *msg_args)

record = self.env[self.model_name]
eval_context = self._get_eval_context(payload=payload)

if self.record_getter:
try:
record = safe_eval(self.record_getter, eval_context)
except Exception as e: # noqa: BLE001
msg = "Webhook #%s could not be triggered because the record_getter failed:\n%s"
msg_args = (self.id, traceback.format_exc())
_logger.warning(msg, *msg_args)
self._webhook_logging(payload, self._add_postmortem(e))
raise e

if not record.exists() and self.allow_creation:
try:
create_eval_context = self._get_create_eval_context(payload=payload)
safe_eval(
self.create_record_code,
create_eval_context,
mode="exec",
nocopy=True,
) # nocopy allows to return 'action'
record = create_eval_context.get("record", self.model_id.browse())
except Exception as e: # noqa: BLE001
msg = "Webhook #%s failed with error:\n%s"
msg_args = (self.id, traceback.format_exc())
_logger.warning(msg, *msg_args)
self._webhook_logging(payload, self._add_postmortem(e))
elif not record.exists():
msg = "Webhook #%s could not be triggered because no record to run it on was found."
msg_args = (self.id,)
_logger.warning(msg, *msg_args)
self._webhook_logging(payload, msg)
raise exceptions.ValidationError(
_("No record to run the automation on was found.")
)

try:
# quirk: base.automation(,)._process has a ``context["__action_done"]``
# at the very beginning of the function while it wasn't set before-hand.
# so setting this context now to avoid further issue advancing forward.
if "__action_done" not in self._context:
self = self.with_context(__action_done={}, payload=payload)
return self._process(record)
except Exception as e: # noqa: BLE001
msg = "Webhook #%s failed with error:\n%s"
msg_args = (self.id, traceback.format_exc())
_logger.warning(msg, *msg_args)
self._webhook_logging(payload, self._add_postmortem(e))
raise e
finally:
self._webhook_logging(payload, None)

def _get_create_eval_context(self, payload=None):
def log(message, level="info"):
with self.pool.cursor() as cr:
cr.execute(
"""
INSERT INTO ir_logging(
create_date, create_uid, type, dbname, name,
level, message, path, line, func
)
VALUES (NOW() at time zone 'UTC', %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
self.env.uid,
"server",
self._cr.dbname,
__name__,
level,
message,
"action",
self.id,
self.name,
),
)

eval_context = dict(self.env.context)
model_name = self.model_id.sudo().model
model = self.env[model_name]
eval_context.update(
{
"uid": self._uid,
"user": self.env.user,
"time": tools.safe_eval.time,
"datetime": tools.safe_eval.datetime,
"dateutil": tools.safe_eval.dateutil,
"timezone": timezone,
"float_compare": float_compare,
"b64encode": base64.b64encode,
"b64decode": base64.b64decode,
"Command": Command,
"env": self.env,
"model": model,
"log": log,
"payload": payload,
}
)
return eval_context

def _webhook_logging(self, body, response):
if self.log_webhook_calls:

vals = {
"webhook_type": "incoming",
"webhook": "%s (%s)" % (self.name, self),
"endpoint": self.url,
"headers": "{}",
"request": ustr(body),
"body": "{}",
"response": ustr(response),
"status": getattr(response, "status_code", None),
}
self.env["webhook.logging"].create(vals)

def _process(self, records, domain_post=None):
"""
Override to allow delay execution
"""
to_delay = self.filtered(lambda a: a.delay_execution)
execute_now = self - to_delay

super(
InheritedBaseAutomation,
to_delay.with_context(delay_execution=True),
)._process(records, domain_post=domain_post)

return super(InheritedBaseAutomation, execute_now)._process(
records, domain_post=domain_post
)
Loading