Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ADD] webhook_incoming: trigger actions upon incoming webhook requests #14

Open
wants to merge 1 commit into
base: 16.0
Choose a base branch
from
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 <[email protected]>

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 <[email protected]>.
# 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 <[email protected]>.
# 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

Check warning on line 13 in webhook_incoming/controllers/main.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/controllers/main.py#L8-L13

Added lines #L8 - L13 were not covered by tests


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 = (

Check warning on line 27 in webhook_incoming/controllers/main.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/controllers/main.py#L27

Added line #L27 was not covered by tests
request.env["base.automation"]
.sudo()
.search([("webhook_uuid", "=", rule_uuid)])
)
if not rule:
return request.make_json_response({"status": "error"}, status=404)

Check warning on line 33 in webhook_incoming/controllers/main.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/controllers/main.py#L33

Added line #L33 was not covered by tests

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)

Check warning on line 40 in webhook_incoming/controllers/main.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/controllers/main.py#L35-L40

Added lines #L35 - L40 were not covered by tests
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 <[email protected]>.
# 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")

Check warning on line 69 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L69

Added line #L69 was not covered by tests
for webhook in self:
webhook.webhook_url = "%s/web/hook/%s" % (base_url, webhook.webhook_uuid)

Check warning on line 71 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L71

Added line #L71 was not covered by tests

@api.depends("trigger", "webhook_uuid")
def _compute_url(self):
for automation in self:
if automation.trigger != "on_webhook":
automation.url = ""

Check warning on line 77 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L77

Added line #L77 was not covered by tests
else:
automation.url = "%s/web/hook/%s" % (

Check warning on line 79 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L79

Added line #L79 was not covered by tests
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

Check warning on line 91 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L88-L91

Added lines #L88 - L91 were not covered by tests

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

Check warning on line 98 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L98

Added line #L98 was not covered by tests

# 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)

Check warning on line 103 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L101-L103

Added lines #L101 - L103 were not covered by tests

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

Check warning on line 106 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L105-L106

Added lines #L105 - L106 were not covered by tests

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

Check warning on line 116 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L109-L116

Added lines #L109 - L116 were not covered by tests

if not record.exists() and self.allow_creation:
try:
create_eval_context = self._get_create_eval_context(payload=payload)
safe_eval(

Check warning on line 121 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L119-L121

Added lines #L119 - L121 were not covered by tests
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))

Check warning on line 132 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L127-L132

Added lines #L127 - L132 were not covered by tests
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(

Check warning on line 138 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L134-L138

Added lines #L134 - L138 were not covered by tests
_("No record to run the automation on was found.")
)

try:

Check warning on line 142 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L142

Added line #L142 was not covered by tests
# 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

Check warning on line 154 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L147-L154

Added lines #L147 - L154 were not covered by tests
finally:
self._webhook_logging(payload, None)

Check warning on line 156 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L156

Added line #L156 was not covered by tests

def _get_create_eval_context(self, payload=None):
def log(message, level="info"):
with self.pool.cursor() as cr:
cr.execute(

Check warning on line 161 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L159-L161

Added lines #L159 - L161 were not covered by tests
"""
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(

Check warning on line 185 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L182-L185

Added lines #L182 - L185 were not covered by tests
{
"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

Check warning on line 203 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L203

Added line #L203 was not covered by tests

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

vals = {

Check warning on line 208 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L208

Added line #L208 was not covered by tests
"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)

Check warning on line 218 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L218

Added line #L218 was not covered by tests

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

Check warning on line 225 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L224-L225

Added lines #L224 - L225 were not covered by tests

super(

Check warning on line 227 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L227

Added line #L227 was not covered by tests
InheritedBaseAutomation,
to_delay.with_context(delay_execution=True),
)._process(records, domain_post=domain_post)

return super(InheritedBaseAutomation, execute_now)._process(

Check warning on line 232 in webhook_incoming/models/base_automation.py

View check run for this annotation

Codecov / codecov/patch

webhook_incoming/models/base_automation.py#L232

Added line #L232 was not covered by tests
records, domain_post=domain_post
)
Loading
Loading