",
+ "boolean": True,
+ "selection": "option1",
+ "date": "2020-01-01",
+ "datetime": "2020-01-01 00:00:00",
+ "binary": "",
+ "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.
diff --git a/odoo/addons/base/tests/test_ir_actions.py b/odoo/addons/base/tests/test_ir_actions.py
index 37787680664..94d5eead0d9 100644
--- a/odoo/addons/base/tests/test_ir_actions.py
+++ b/odoo/addons/base/tests/test_ir_actions.py
@@ -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'
diff --git a/odoo/addons/base/views/ir_actions_views.xml b/odoo/addons/base/views/ir_actions_views.xml
index 695872f0146..9a4a30699a2 100644
--- a/odoo/addons/base/views/ir_actions_views.xml
+++ b/odoo/addons/base/views/ir_actions_views.xml
@@ -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}"/>
-
+
+
+
+
+
+
+