diff --git a/setup/webhook_incoming/odoo/addons/webhook_incoming b/setup/webhook_incoming/odoo/addons/webhook_incoming new file mode 120000 index 0000000..65da669 --- /dev/null +++ b/setup/webhook_incoming/odoo/addons/webhook_incoming @@ -0,0 +1 @@ +../../../../webhook_incoming \ No newline at end of file diff --git a/setup/webhook_incoming/setup.py b/setup/webhook_incoming/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/webhook_incoming/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..34e31da --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-webhook_outgoing @ git+https://github.com/OCA/webhook@refs/pull/13/head#subdirectory=setup/webhook_outgoing diff --git a/webhook_incoming/README.rst b/webhook_incoming/README.rst new file mode 100644 index 0000000..7d164e6 --- /dev/null +++ b/webhook_incoming/README.rst @@ -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 `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Hoang Tran + +Contributors +~~~~~~~~~~~~ + +* Hoang Tran + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/webhook `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/webhook_incoming/__init__.py b/webhook_incoming/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/webhook_incoming/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/webhook_incoming/__manifest__.py b/webhook_incoming/__manifest__.py new file mode 100644 index 0000000..c2b0751 --- /dev/null +++ b/webhook_incoming/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2024 Hoang Tran . +# 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, +} diff --git a/webhook_incoming/controllers/__init__.py b/webhook_incoming/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/webhook_incoming/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/webhook_incoming/controllers/main.py b/webhook_incoming/controllers/main.py new file mode 100644 index 0000000..e95885b --- /dev/null +++ b/webhook_incoming/controllers/main.py @@ -0,0 +1,40 @@ +# Copyright 2024 Hoang Tran . +# 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/"], + 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) diff --git a/webhook_incoming/models/__init__.py b/webhook_incoming/models/__init__.py new file mode 100644 index 0000000..be53e2b --- /dev/null +++ b/webhook_incoming/models/__init__.py @@ -0,0 +1,2 @@ +from . import base_automation +from . import ir_actions_server diff --git a/webhook_incoming/models/base_automation.py b/webhook_incoming/models/base_automation.py new file mode 100644 index 0000000..08dea96 --- /dev/null +++ b/webhook_incoming/models/base_automation.py @@ -0,0 +1,234 @@ +# Copyright 2024 Hoang Tran . +# 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 + ) diff --git a/webhook_incoming/models/ir_actions_server.py b/webhook_incoming/models/ir_actions_server.py new file mode 100644 index 0000000..153b899 --- /dev/null +++ b/webhook_incoming/models/ir_actions_server.py @@ -0,0 +1,21 @@ +# Copyright 2024 Hoang Tran . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import api, models + + +class IrActionsServer(models.Model): + _inherit = "ir.actions.server" + + def run(self): + if self.env.context.get("delay_execution"): + return self.with_delay().run() + return super().run() + + @api.model + def _job_prepare_context_before_enqueue_keys(self): + return ( + "active_model", + "active_ids", + "active_id", + "domain_post", + ) diff --git a/webhook_incoming/readme/CONTRIBUTORS.rst b/webhook_incoming/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..4d9e6f4 --- /dev/null +++ b/webhook_incoming/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Hoang Tran diff --git a/webhook_incoming/readme/DESCRIPTION.rst b/webhook_incoming/readme/DESCRIPTION.rst new file mode 100644 index 0000000..17839c8 --- /dev/null +++ b/webhook_incoming/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ +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. diff --git a/webhook_incoming/static/description/index.html b/webhook_incoming/static/description/index.html new file mode 100644 index 0000000..5cdcd9e --- /dev/null +++ b/webhook_incoming/static/description/index.html @@ -0,0 +1,429 @@ + + + + + +Incoming Webhook + + + +
+

Incoming Webhook

+ + +

Beta License: LGPL-3 OCA/webhook Translate me on Weblate Try me on Runboat

+

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

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Hoang Tran
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

This module is part of the OCA/webhook project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/webhook_incoming/views/base_automation_views.xml b/webhook_incoming/views/base_automation_views.xml new file mode 100644 index 0000000..8c69cbb --- /dev/null +++ b/webhook_incoming/views/base_automation_views.xml @@ -0,0 +1,50 @@ + + + + webhook.incoming.view.automation.form + base.automation + + + + + + + + + + + + + + + + + + + +