[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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'">
|
||||
|
||||
Reference in New Issue
Block a user