From b846d1ebe9129a11ee0678aa287784e767ff50e5 Mon Sep 17 00:00:00 2001 From: Lucas Perais Date: Tue, 3 Oct 2023 09:24:51 +0200 Subject: [PATCH] [IMP] base_automation: expose as webhook client Allow third parties services to contact a base_automation via the route "/web/hook/" Base Automations now have an additional field "record_getter", which contains a python expression that should return a record set on which the webhook will apply. A base automation configured as a webhook takes in its eval_context, the "payload", that is, a shortcut to the request's data. task-id-3450200 Part-of: odoo/odoo#135932 Co-authored-by: Damien Bouvy --- addons/base_automation/__init__.py | 1 + .../base_automation/controllers/__init__.py | 1 + addons/base_automation/controllers/main.py | 23 ++++ .../base_automation/models/base_automation.py | 102 +++++++++++++++++- ...base_automation_trigger_selection_field.js | 4 + addons/base_automation/static/src/utils.js | 1 + .../views/base_automation_views.xml | 36 ++++++- .../static/tests/tour/base_automation_tour.js | 13 +-- .../test_base_automation/tests/test_flow.py | 30 ++++++ 9 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 addons/base_automation/controllers/__init__.py create mode 100644 addons/base_automation/controllers/main.py diff --git a/addons/base_automation/__init__.py b/addons/base_automation/__init__.py index dc5e6b693d1..8c67f893b37 100644 --- a/addons/base_automation/__init__.py +++ b/addons/base_automation/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import models +from . import controllers diff --git a/addons/base_automation/controllers/__init__.py b/addons/base_automation/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/addons/base_automation/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/base_automation/controllers/main.py b/addons/base_automation/controllers/main.py new file mode 100644 index 00000000000..73eb99999b2 --- /dev/null +++ b/addons/base_automation/controllers/main.py @@ -0,0 +1,23 @@ +from odoo.http import request, route, Controller + +from json import JSONDecodeError + +class BaseAutomationController(Controller): + + @route(['/web/hook/'], type='http', auth='none', 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) + + try: + data = request.get_json_data() + except JSONDecodeError: + data = kwargs or {} + + 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/addons/base_automation/models/base_automation.py b/addons/base_automation/models/base_automation.py index 420d76aa9cf..f0f26ac6e80 100644 --- a/addons/base_automation/models/base_automation.py +++ b/addons/base_automation/models/base_automation.py @@ -5,6 +5,7 @@ import datetime import logging import traceback from collections import defaultdict +from uuid import uuid4 from dateutil.relativedelta import relativedelta @@ -82,6 +83,10 @@ class BaseAutomation(models.Model): store=True, readonly=False, ) + url = fields.Char(compute='_compute_url') + webhook_uuid = fields.Char(string="Webhook UUID", readonly=True, copy=False, default=lambda self: str(uuid4())) + record_getter = fields.Char(help="This code will be run to find on which record the automation rule should be run.\nExample: model.browse(payload.get('recordId')))") + log_webhook_calls = fields.Boolean(string="Log Calls", default=False) active = fields.Boolean(default=True, help="When unchecked, the rule is hidden and will not be executed.") @api.constrains("trigger", "model_id") @@ -112,6 +117,8 @@ class BaseAutomation(models.Model): ("on_message_received", "A message was received from an external user"), ("on_message_sent", "A message was sent to an external user"), + + ('on_webhook', "On webhook"), ], string='Trigger', compute='_compute_trigger_and_trigger_field_ids', readonly=False, store=True, required=True) trg_selection_field_id = fields.Many2one( @@ -202,6 +209,17 @@ class BaseAutomation(models.Model): action_names=', '.join(failing_actions.mapped('name')) ) ) + @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 _inverse_model_name(self): + for rec in self: + rec.model_id = self.env["ir.model"]._get(rec.model_name) @api.constrains('trigger', 'action_server_ids') def _check_trigger_state(self): @@ -238,7 +256,6 @@ class BaseAutomation(models.Model): for record in (self - to_reset): record.trg_date_id = record.trigger_field_ids - @api.depends('trigger') def _compute_trg_date_range_data(self): to_reset = self.filtered(lambda a: a.trigger not in TIME_TRIGGERS) @@ -408,6 +425,79 @@ class BaseAutomation(models.Model): self._update_registry() return res + def action_rotate_webhook_uuid(self): + for automation in self: + automation.webhook_uuid = str(uuid4()) + + def action_view_webhook_logs(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Webhook Logs'), + 'res_model': 'ir.logging', + 'view_mode': 'tree,form', + 'domain': [('path', '=', "base_automation(%s)" % self.id)], + } + + def _prepare_loggin_values(self, **values): + self.ensure_one() + defaults = { + 'name': _("Webhook Log"), + 'type': 'server', + 'dbname': self._cr.dbname, + 'level': 'INFO', + 'path': "base_automation(%s)" % self.id, + 'func': '', + 'line': '' + } + defaults.update(**values) + return defaults + + 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() + ir_logging_sudo = self.env['ir.logging'].sudo() + + # 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) + if self.log_webhook_calls: + ir_logging_sudo.create(self._prepare_loggin_values(message=msg % msg_args)) + + record = self.env[self.model_name] + if self.record_getter: + try: + record = safe_eval.safe_eval(self.record_getter, self._get_eval_context(payload=payload)) + 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) + if self.log_webhook_calls: + ir_logging_sudo.create(self._prepare_loggin_values(message=msg % msg_args, level="ERROR")) + raise e + + if 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) + if self.log_webhook_calls: + ir_logging_sudo.create(self._prepare_loggin_values(message=msg % msg_args, level="ERROR")) + raise exceptions.ValidationError(_("No record to run the automation on was found.")) + + try: + 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) + if self.log_webhook_calls: + ir_logging_sudo.create(self._prepare_loggin_values(message=msg % msg_args, level="ERROR")) + raise e + def _update_cron(self): """ Activate the cron job depending on whether there exists automation rules based on time conditions. Also update its frequency according to @@ -443,17 +533,23 @@ class BaseAutomation(models.Model): automations = self.with_context(active_test=True).sudo().search(domain) return automations.with_env(self.env) - def _get_eval_context(self): + def _get_eval_context(self, payload=None): """ Prepare the context used when evaluating python code :returns: dict -- evaluation context given to safe_eval """ - return { + self.ensure_one() + model = self.env[self.model_name] + eval_context = { 'datetime': safe_eval.datetime, 'dateutil': safe_eval.dateutil, 'time': safe_eval.time, 'uid': self.env.uid, 'user': self.env.user, + 'model': model, } + if payload is not None: + eval_context['payload'] = payload + return eval_context def _get_cron_interval(self, automations=None): """ Return the expected time interval used by the cron, in minutes. """ diff --git a/addons/base_automation/static/src/base_automation_trigger_selection_field.js b/addons/base_automation/static/src/base_automation_trigger_selection_field.js index 9a45207c264..d5debaea358 100644 --- a/addons/base_automation/static/src/base_automation_trigger_selection_field.js +++ b/addons/base_automation/static/src/base_automation_trigger_selection_field.js @@ -30,6 +30,10 @@ const OPT_GROUPS = [ group: { sequence: 40, key: "custom", name: _lt("Custom") }, triggers: ["on_unlink", "on_change"], }, + { + group: { sequence: 50, key: "external", name: _lt("External") }, + triggers: ["on_webhook"], + }, { group: { sequence: 20, key: "mail", name: _t("Email Events") }, triggers: ["on_message_sent", "on_message_received"], diff --git a/addons/base_automation/static/src/utils.js b/addons/base_automation/static/src/utils.js index beeff335f79..21e4c4c5f39 100644 --- a/addons/base_automation/static/src/utils.js +++ b/addons/base_automation/static/src/utils.js @@ -21,4 +21,5 @@ export const TRIGGER_FILTERS = { f.ttype === "selection" && ["priority", "x_studio_priority"].includes(f.name), on_archive: (f) => f.ttype === "boolean" && ["active", "x_active"].includes(f.name), on_unarchive: (f) => f.ttype === "boolean" && ["active", "x_active"].includes(f.name), + on_webhook: (f) => true, }; diff --git a/addons/base_automation/views/base_automation_views.xml b/addons/base_automation/views/base_automation_views.xml index 469f4f62e1c..0af2086f25f 100644 --- a/addons/base_automation/views/base_automation_views.xml +++ b/addons/base_automation/views/base_automation_views.xml @@ -8,8 +8,12 @@
+
+ +
- +

@@ -60,6 +64,20 @@
Automation rules that run on live updates will be executed every time the trigger fields change, whether you save or not.
+