[IMP] base_automation: expose as webhook client

Allow third parties services to contact a base_automation via the route "/web/hook/<uuid>"
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 <dbo@odoo.com>
This commit is contained in:
Lucas Perais
2023-10-20 09:35:12 +00:00
co-authored by Damien Bouvy
parent 542b455561
commit b846d1ebe9
9 changed files with 201 additions and 10 deletions
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import controllers
@@ -0,0 +1 @@
from . import main
@@ -0,0 +1,23 @@
from odoo.http import request, route, Controller
from json import JSONDecodeError
class BaseAutomationController(Controller):
@route(['/web/hook/<string:rule_uuid>'], 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)
@@ -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. """
@@ -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"],
@@ -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,
};
@@ -8,8 +8,12 @@
<field name="arch" type="xml">
<form string="Automation Rule">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_webhook_logs" type="object" string="Logs" class="oe_stat_button" icon="fa-list" invisible="trigger != 'on_webhook'">
</button>
</div>
<field name="active" invisible="1" />
<field name="model_name" invisible="1" />
<field name="model_name" invisible="1" force_save="True" />
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" invisible="active"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Support flow"/></h1>
@@ -60,6 +64,20 @@
</div>
<div class="text-muted" invisible="trigger != 'on_change'"><i class="fa fa-lightbulb-o"/> Automation rules that run on live updates will be executed every time the trigger fields change, <em>whether you save or not</em>.</div>
</div>
<label for="url" string="URL" invisible="trigger != 'on_webhook'"/>
<div invisible="trigger != 'on_webhook'">
<field name="url" widget="CopyClipboardURL" placeholder="URL will be created once the rule is saved."/>
<div class="alert alert-warning" role="status">
<strong><i class="fa fa-lock"/> Keep it secret, keep it safe.</strong>
<p>Your webhook URL contains a secret. Don't share it online or carelessly.</p>
<button class="btn btn-seconadry" type="object" name="action_rotate_webhook_uuid" string="Rotate Secret" icon="fa-refresh" help="Change the URL's secret if you think the URL is no longer secure. You will have to update any automated system that calls this webhook to the new URL."/>
</div>
</div>
<field name="log_webhook_calls" widget="boolean_toggle" invisible="trigger != 'on_webhook'"/>
<field name="trg_date_id" class="oe_inline" string="Date Field"
options="{'no_open': True, 'no_create': True}"
invisible="trigger != 'on_time'"
required="trigger in ['on_time', 'on_time_created', 'on_time_updated']"/>
<label for="trg_date_range" class="oe_inline" string="Delay"
invisible="trigger not in ['on_time', 'on_time_created', 'on_time_updated']"/>
<div class="o_row oe_inline" invisible="trigger not in ['on_time', 'on_time_created', 'on_time_updated']" >
@@ -75,12 +93,28 @@
</div>
<field name="filter_pre_domain" class="oe_inline" widget="domain" groups="base.group_no_one"
options="{'model': 'model_name', 'in_dialog': True}"
invisible="trigger == 'on_webhook'"
/>
<field name="filter_domain" class="oe_inline" widget="domain"
options="{'model': 'model_name', 'in_dialog': True}"
invisible="trigger not in ['on_create_or_write', 'on_change', 'on_unlink']"
/>
</group>
<group>
<label for="record_getter" string="Target Record" invisible="trigger != 'on_webhook'" />
<div invisible="trigger != 'on_webhook'">
<field name="record_getter" string="Target Record"/>
<div>
<span colspan="2" class="text-muted"> Available variables: </span>
<ul colspan="2" class="text-muted">
<li><code>env</code>: environment on which the action is triggered</li>
<li><code>model</code>: model of the record on which the action is triggered; is a void recordset</li>
<li><code>time</code>, <code>datetime</code>, <code>dateutil</code>, <code>timezone</code>: useful Python libraries</li>
<li><code>payload</code>: the payload of the call (GET parameters, JSON body), as a dict.</li>
</ul>
</div>
</div>
</group>
</group>
<notebook invisible="not model_id">
<page string="Actions To Do" name="actions">
@@ -148,6 +148,7 @@ registry.category("web_tour.tours").add("test_base_automation_on_tag_added", {
on_time_updated: "After last update",
on_unlink: "On deletion",
on_change: "On live update",
on_webhook: "On webhook",
})
);
},
@@ -449,11 +450,11 @@ registry.category("web_tour.tours").add("test_form_view_model_id", {
const triggerGroups = Array.from(this.$anchor[0].querySelectorAll("optgroup"));
assertEqual(
triggerGroups.map((el) => el.getAttribute("label")).join(" // "),
"Values Updated // Timing Conditions // Custom"
"Values Updated // Timing Conditions // Custom // External"
);
assertEqual(
triggerGroups.map((el) => el.innerText).join(" // "),
"User is setOn save // Based on date fieldAfter creationAfter last update // On deletionOn live update"
"User is setOn save // Based on date fieldAfter creationAfter last update // On deletionOn live update // On webhook"
);
},
},
@@ -483,11 +484,11 @@ registry.category("web_tour.tours").add("test_form_view_model_id", {
const triggerGroups = Array.from(this.$anchor[0].querySelectorAll("optgroup"));
assertEqual(
triggerGroups.map((el) => el.getAttribute("label")).join(" // "),
"Values Updated // Timing Conditions // Custom"
"Values Updated // Timing Conditions // Custom // External"
);
assertEqual(
triggerGroups.map((el) => el.innerText).join(" // "),
"Stage is set toUser is setTag is addedPriority is set toOn save // Based on date fieldAfter creationAfter last update // On deletionOn live update"
"Stage is set toUser is setTag is addedPriority is set toOn save // Based on date fieldAfter creationAfter last update // On deletionOn live update // On webhook"
);
},
},
@@ -565,7 +566,7 @@ registry.category("web_tour.tours").add("test_form_view_mail_triggers", {
{
trigger: ".o_field_widget[name='trigger'] select",
run() {
assertEqual(Array.from(this.$anchor[0].querySelectorAll("optgroup")).map(el => el.label).join(", "), "Values Updated, Timing Conditions, Custom")
assertEqual(Array.from(this.$anchor[0].querySelectorAll("optgroup")).map(el => el.label).join(", "), "Values Updated, Timing Conditions, Custom, External")
}
},
{
@@ -591,7 +592,7 @@ registry.category("web_tour.tours").add("test_form_view_mail_triggers", {
{
trigger: ".o_field_widget[name='trigger']",
run() {
assertEqual(Array.from(this.$anchor[0].querySelectorAll("select optgroup")).map(el => el.label).join(", "), "Values Updated, Email Events, Timing Conditions, Custom")
assertEqual(Array.from(this.$anchor[0].querySelectorAll("select optgroup")).map(el => el.label).join(", "), "Values Updated, Email Events, Timing Conditions, Custom, External")
}
},
{
@@ -1008,3 +1008,33 @@ class TestCompute(common.TransactionCase):
obj.active = True
obj.message_post(author_id=ext_partner.id, subtype_xmlid="mail.mt_comment")
self.assertTrue(obj.active)
@common.tagged("post_install", "-at_install")
class TestHttp(common.HttpCase):
def test_webhook_trigger(self):
self.authenticate(None, None)
model = self.env["ir.model"]._get("base.automation.linked.test")
record_getter = "model.search([('name', '=', payload['name'])]) if payload.get('name') else None"
automation = create_automation(self, trigger="on_webhook", model_id=model.id, record_getter=record_getter, log_webhook_calls=True, _actions={
"state": "object_write",
"update_field_id": self.env["ir.model.fields"]._get(model.model, "another_field").id,
"value": "written"
})
obj = self.env[model.model].create({"name": "some name"})
response = self.url_open(automation.url, data={"name": "some name"})
self.assertEqual(response.json(), {"status": "ok"})
self.assertEqual(response.status_code, 200)
self.assertEqual(obj.another_field, "written")
obj.another_field = False
with mute_logger("odoo.addons.base_automation.models.base_automation"):
response = self.url_open(automation.url, data={})
self.assertEqual(response.json(), {"status": "error"})
self.assertEqual(response.status_code, 500)
self.assertEqual(obj.another_field, False)
response = self.url_open("/web/hook/0123456789", data={"name": "some name"})
self.assertEqual(response.json(), {"status": "error"})
self.assertEqual(response.status_code, 404)