[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:
co-authored by
Damien Bouvy
parent
542b455561
commit
b846d1ebe9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user