[IMP] base: add 'webhook'-type server actions

Webhook server actions allow users to send a POST request to an external
system, e.g. Slack, Github or even another Odoo instance.

This is a rather simple setup, as this feature does not include frequent
features of typical webhooks, such as the possibility to use headers for
authentication, or a retry-policy with exponential decay and all the
bells and whistles of that style.

However, this can be useful for simple actions and automations.

Task-3450200

Part-of: odoo/odoo#138804
This commit is contained in:
Damien Bouvy
2023-10-26 13:41:07 +00:00
parent 1a8e2c567c
commit b3efb92f6b
3 changed files with 154 additions and 17 deletions
+107 -16
View File
@@ -3,17 +3,18 @@
import odoo
from odoo import api, fields, models, tools, _, Command
from odoo.exceptions import MissingError, ValidationError, AccessError
from odoo.exceptions import MissingError, ValidationError, AccessError, UserError
from odoo.tools import frozendict
from odoo.tools.safe_eval import safe_eval, test_python_expr
from odoo.tools.float_utils import float_compare
from odoo.http import request
import base64
from collections import defaultdict
from functools import partial, reduce
import logging
from operator import getitem
import requests
import json
from pytz import timezone
@@ -401,6 +402,25 @@ class IrActionsActUrl(models.Model):
"target", "url", "close",
}
WEBHOOK_SAMPLE_VALUES = {
"integer": 42,
"float": 42.42,
"monetary": 42.42,
"char": "Hello World",
"text": "Hello World",
"html": "<p>Hello World</p>",
"boolean": True,
"selection": "option1",
"date": "2020-01-01",
"datetime": "2020-01-01 00:00:00",
"binary": "<base64_data>",
"many2one": 47,
"many2many": [42, 47],
"one2many": [42, 47],
"reference": "res.partner,42",
None: "some_data",
}
class IrActionsServer(models.Model):
""" Server actions model. Server action work on a base model and offer various
@@ -462,6 +482,7 @@ class IrActionsServer(models.Model):
('object_write', 'Update Record'),
('object_create', 'Create Record'),
('code', 'Execute Code'),
('webhook', 'Send Webhook Notification'),
('multi', 'Execute existing actions')], string='Type',
default='object_write', required=True, copy=True,
help="Type of server action. The following values are available:\n"
@@ -472,6 +493,7 @@ class IrActionsServer(models.Model):
"- 'Add/Remove Followers': add or remove followers to a record (Discuss)\n"
"- 'Create Record': create a new record with new values\n"
"- 'Execute Code': a block of Python code that will be executed\n"
"- 'Send Webhook Notification': send a POST request to an external system, also known as a Webhook\n"
"- 'Execute existing actions': define an action that triggers several other server actions\n")
# Generic
sequence = fields.Integer(default=5,
@@ -526,21 +548,34 @@ class IrActionsServer(models.Model):
('resource_ref', 'reference'),
('selection_value', 'selection_value'),
], compute='_compute_value_field_to_show')
# Webhook
webhook_url = fields.Char(string='Webhook URL', help="URL to send the POST request to.")
webhook_field_ids = fields.Many2many('ir.model.fields', 'ir_act_server_webhook_field_rel', 'server_id', 'field_id',
string='Webhook Fields',
help="Fields to send in the POST request. "
"The id and model of the record are always sent as '_id' and '_model'. "
"The name of the action that triggered the webhook is always sent as '_name'.")
webhook_sample_payload = fields.Text(string='Sample Payload', compute='_compute_webhook_sample_payload')
@api.depends('state', 'update_field_id', 'crud_model_id', 'value')
def _compute_name(self):
for action in self.filtered('state'):
if action.state == 'object_write':
action.name = _("Update %s", action._stringify_path())
elif action.state == 'object_create':
action.name = _(
"Create %(model_name)s with name %(value)s",
model_name=action.crud_model_id.name,
value=action.value
)
else:
action.name = dict(action._fields['state']._description_selection(self.env))[action.state]
@api.constrains('webhook_field_ids')
def _check_webhook_field_ids(self):
"""Check that the selected fields don't have group restrictions"""
restricted_fields = dict()
for action in self:
Model = self.env[action.model_id.model]
for model_field in action.webhook_field_ids:
# you might think that the ir.model.field record holds references
# to the groups, but that's not the case - we need to field object itself
field = Model._fields[model_field.name]
if field.groups:
restricted_fields.setdefault(action.name, []).append(model_field.field_description)
if restricted_fields:
restricted_field_per_action = "\n".join([f"{action}: {', '.join(f for f in fields)}" for action, fields in restricted_fields.items()])
raise ValidationError(_("Group-restricted fields cannot be included in "
"webhook payloads, as it could allow any user to "
"accidentally leak sensitive information. You will "
"have to remove the following fields from the webhook payload "
"in the following actions:\n %s", restricted_field_per_action))
@api.depends('state')
def _compute_available_model_ids(self):
@@ -635,6 +670,26 @@ class IrActionsServer(models.Model):
pretty_path.append(field_id.field_description)
return ' > '.join(pretty_path)
@api.depends('state', 'webhook_field_ids', 'name')
def _compute_webhook_sample_payload(self):
for action in self:
if action.state != 'webhook':
action.webhook_sample_payload = False
continue
payload = {
'id': 1,
'_model': self.model_id.model,
'_name': action.name,
}
sample_record = self.env[self.model_id.model].with_context(active_test=False).search([], limit=1)
for field in action.webhook_field_ids:
if sample_record:
payload['id'] = sample_record.id
payload.update(sample_record.read(self.webhook_field_ids.mapped('name'), load=None)[0])
else:
payload[field.name] = WEBHOOK_SAMPLE_VALUES[field.ttype] if field.ttype in WEBHOOK_SAMPLE_VALUES else WEBHOOK_SAMPLE_VALUES[None]
action.webhook_sample_payload = json.dumps(payload, indent=4, sort_keys=True, default=str)
@api.depends('model_id')
def _compute_link_field_id(self):
invalid = self.filtered(lambda act: act.link_field_id.model_id != act.model_id)
@@ -719,6 +774,42 @@ class IrActionsServer(models.Model):
_, _, target_records = self._traverse_path(record=starting_record)
target_records.write(res)
def _run_action_webhook(self, eval_context=None):
"""Send a post request with a read of the selected field on active_id."""
record = self.env[self.model_id.model].browse(self._context.get('active_id'))
url = self.webhook_url
if not record:
return
if not url:
raise UserError(_("I'll be happy to send a webhook for you, but you really need to give me a URL to reach out to..."))
vals = {
'_model': self.model_id.model,
'_id': record.id,
'_action': f'{self.name}(#{self.id})',
}
if self.webhook_field_ids:
# you might think we could use the default json serializer of the requests library
# but it will fail on many fields, e.g. datetime, date or binary
# so we use the json.dumps serializer instead with the str() function as default
vals.update(record.read(self.webhook_field_ids.mapped('name'), load=None)[0])
json_values = json.dumps(vals, sort_keys=True, default=str)
_logger.info("Webhook call to %s", url)
_logger.debug("POST JSON data for webhook call: %s", json_values)
try:
# 'send and forget' strategy, and avoid locking the user if the webhook
# is slow or non-functional (we still allow for a 1s timeout so that
# if we get a proper error response code like 400, 404 or 500 we can log)
response = requests.post(url, data=json_values, headers={'Content-Type': 'application/json'}, timeout=1)
response.raise_for_status()
except requests.exceptions.ReadTimeout:
_logger.warning("Webhook call timed out after 1s - it may or may not have failed. "
"If this happens often, it may be a sign that the system you're "
"trying to reach is slow or non-functional.")
except requests.exceptions.RequestException as e:
_logger.warning("Webhook call failed: %s", e)
except Exception as e: # noqa: BLE001
raise UserError(_("Wow, your webhook call failed with a really unusual error: %s", e)) from e
def _run_action_object_create(self, eval_context=None):
"""Create specified model object with specified name contained in value.
+40
View File
@@ -2,7 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
import json
from psycopg2 import IntegrityError, ProgrammingError
import requests
from unittest.mock import patch
import odoo
from odoo.exceptions import UserError, ValidationError, AccessError
@@ -404,6 +407,43 @@ ZeroDivisionError: division by zero""" % self.test_server_action.id
self_demo.with_context(self.context).run()
self.assertEqual(self.test_partner.date, date.today())
def test_90_webhook(self):
self.action.write({
'state': 'webhook',
'webhook_field_ids': [
Command.link(self.res_partner_name_field.id),
Command.link(self.res_partner_city_field.id),
Command.link(self.res_partner_country_field.id),
],
'webhook_url': 'http://example.com/webhook',
})
# write a mock for the requests.post method that checks the data
# and returns a 200 response
num_requests = 0
def _patched_post(*args, **kwargs):
nonlocal num_requests
response = requests.Response()
response.status_code = 200 if num_requests == 0 else 400
self.assertEqual(args[0], 'http://example.com/webhook')
self.assertEqual(kwargs['data'], json.dumps({
'_action': "%s(#%s)" % (self.action.name, self.action.id),
'_id': self.test_partner.id,
'_model': self.test_partner._name,
'city': self.test_partner.city,
'country_id': self.test_partner.country_id.id,
'id': self.test_partner.id,
'name': self.test_partner.name,
}))
num_requests += 1
return response
with patch.object(requests, 'post', _patched_post), mute_logger('odoo.addons.base.models.ir_actions'):
# first run: 200
self.action.with_context(self.context).run()
# second run: 400, should *not* raise but
# should warn in logs (hence mute_logger)
self.action.with_context(self.context).run()
self.assertEqual(num_requests, 2)
class TestCommonCustomFields(common.TransactionCase):
MODEL = 'res.partner'
+7 -1
View File
@@ -389,7 +389,13 @@
options="{'no_create': True, 'no_open': True}"
invisible="state != 'object_create'"
context="{'default_model_id': model_id, 'default_relation': crud_model_name}"/>
</group>
<field name="webhook_url" string="URL" invisible="state != 'webhook'" placeholder="e.g. https://maker.ifttt.com/use/..."/>
<field name="webhook_field_ids" string="Fields" invisible="state != 'webhook'" widget="many2many_tags" context="{'hide_model': 1}" domain="[('model_id', '=', model_id)]" placeholder="Select fields to include in the request..."/>
</group>
<group invisible="state != 'webhook'">
<label for="webhook_sample_payload" />
<field name="webhook_sample_payload" string="Sample Payload" nolabel="1" colspan="2" readonly="1" widget="code" options="{'mode': 'js'}" />
</group>
</group>
<field name="child_ids" domain="[('model_id', '=', model_id)]" invisible="state != 'multi'"/>
<p class="oe_grey" invisible="state != 'multi'">