diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index 4c50c8da4d2..b6ab4fcb02f 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -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": "

Hello World

", + "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}"/> - + + + + +