From 542b4555619dfa0e2b4f0484522132c2ccaee815 Mon Sep 17 00:00:00 2001 From: Lucas Perais Date: Tue, 3 Oct 2023 08:46:42 +0200 Subject: [PATCH] [IMP] base_automation: implement mail triggers This commit implements mail triggers on base automation as follows: - on_message_sent/on_message_received are new triggers on base_automation - only reacts on message_post, an heuristic has been chosen to determine whether a message has been sent to an external partner, or received from one. task-id-3450200 Part-of: odoo/odoo#135932 --- .../base_automation/models/base_automation.py | 44 +++++++++++++-- ...base_automation_trigger_selection_field.js | 29 ++++++---- .../models/test_base_automation.py | 5 ++ .../static/tests/tour/base_automation_tour.js | 53 +++++++++++++++++++ .../test_base_automation/tests/test_flow.py | 51 +++++++++++++++++- .../test_base_automation/tests/test_tour.py | 9 ++++ 6 files changed, 178 insertions(+), 13 deletions(-) diff --git a/addons/base_automation/models/base_automation.py b/addons/base_automation/models/base_automation.py index 571dd2b3834..420d76aa9cf 100644 --- a/addons/base_automation/models/base_automation.py +++ b/addons/base_automation/models/base_automation.py @@ -54,6 +54,8 @@ WRITE_TRIGGERS = [ 'on_user_set', ] +MAIL_TRIGGERS = ("on_message_received", "on_message_sent") + CREATE_WRITE_SET = set(CREATE_TRIGGERS + WRITE_TRIGGERS) TIME_TRIGGERS = [ @@ -71,7 +73,8 @@ class BaseAutomation(models.Model): model_id = fields.Many2one( "ir.model", string="Model", required=True, ondelete="cascade", help="Model on which the automation rule runs." ) - model_name = fields.Char(related="model_id.model", string="Model Name", readonly=True) + model_name = fields.Char(related="model_id.model", string="Model Name", readonly=True, inverse="_inverse_model_name") + model_is_mail_thread = fields.Boolean(related="model_id.is_mail_thread") action_server_ids = fields.One2many("ir.actions.server", "base_automation_id", context={'default_usage': 'base_automation'}, string="Actions", @@ -80,6 +83,13 @@ class BaseAutomation(models.Model): readonly=False, ) active = fields.Boolean(default=True, help="When unchecked, the rule is hidden and will not be executed.") + + @api.constrains("trigger", "model_id") + def _check_trigger(self): + for automation in self: + if automation.trigger in MAIL_TRIGGERS and not automation.model_id.is_mail_thread: + raise exceptions.ValidationError(_("Mail event can not be configured on model %s. Only models with discussion feature can be used.", automation.model_id.name)) + trigger = fields.Selection( [ ('on_stage_set', "Stage is set to"), @@ -99,6 +109,9 @@ class BaseAutomation(models.Model): ('on_time', "Based on date field"), ('on_time_created', "After creation"), ('on_time_updated', "After last update"), + + ("on_message_received", "A message was received from an external user"), + ("on_message_sent", "A message was sent to an external user"), ], string='Trigger', compute='_compute_trigger_and_trigger_field_ids', readonly=False, store=True, required=True) trg_selection_field_id = fields.Many2one( @@ -338,6 +351,8 @@ class BaseAutomation(models.Model): else: automation.trigger_field_ids = False continue + if automation.model_id.is_mail_thread and automation.trigger in MAIL_TRIGGERS: + continue automation.trigger_field_ids = self.env['ir.model.fields'].search(domain, limit=1) automation.trigger = False if not automation.trigger_field_ids else automation.trigger @@ -490,7 +505,7 @@ class BaseAutomation(models.Model): def _process(self, records, domain_post=None): """ Process automation ``self`` on the ``records`` that have not been done yet. """ # filter out the records on which self has already been done - automation_done = self._context['__action_done'] + automation_done = self._context.get('__action_done', {}) records_done = automation_done.get(self, records.browse()) records -= records_done if not records: @@ -722,9 +737,32 @@ class BaseAutomation(models.Model): for field in automation_rule.on_change_field_ids: Model._onchange_methods[field.name].append(method) + if automation_rule.model_id.is_mail_thread and automation_rule.trigger in MAIL_TRIGGERS: + def _message_post(self, *args, **kwargs): + message = _message_post.origin(self, *args, **kwargs) + # Don't execute automations for a message emitted during + # the run of automations for a real message + # Don't execute if we know already that a message is only internal + message_sudo = message.sudo().with_context(active_test=False) + if "__action_done" in self.env.context or message_sudo.is_internal or message_sudo.subtype_id.internal: + return message + if message_sudo.message_type in ('notification', 'auto_comment', 'user_notification'): + return message + + # always execute actions when the author is a customer + mail_trigger = "on_message_received" if message_sudo.author_id.partner_share else "on_message_sent" + automations = self.env['base.automation']._get_actions(self, [mail_trigger]) + for automation in automations.with_context(old_values=None): + records = automation._filter_pre(self) + automation._process(records) + + return message + + patch(Model, "message_post", _message_post) + def _unregister_hook(self): """ Remove the patches installed by _register_hook() """ - NAMES = ['create', 'write', '_compute_field_value', 'unlink', '_onchange_methods'] + NAMES = ['create', 'write', '_compute_field_value', 'unlink', '_onchange_methods', "message_post"] for Model in self.env.registry.values(): for name in NAMES: try: diff --git a/addons/base_automation/static/src/base_automation_trigger_selection_field.js b/addons/base_automation/static/src/base_automation_trigger_selection_field.js index 14e12896cc9..9a45207c264 100644 --- a/addons/base_automation/static/src/base_automation_trigger_selection_field.js +++ b/addons/base_automation/static/src/base_automation_trigger_selection_field.js @@ -1,7 +1,7 @@ /** @odoo-module */ import { useState } from "@odoo/owl"; -import { _lt } from "@web/core/l10n/translation"; +import { _lt, _t } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; import { useRecordObserver } from "@web/model/relational_model/utils"; import { selectionField, SelectionField } from "@web/views/fields/selection/selection_field"; @@ -31,25 +31,34 @@ const OPT_GROUPS = [ triggers: ["on_unlink", "on_change"], }, { - group: { sequence: 50, key: "deprecated", name: _lt("Deprecated (do not use)") }, + group: { sequence: 20, key: "mail", name: _t("Email Events") }, + triggers: ["on_message_sent", "on_message_received"], + }, + { + group: { sequence: 60, key: "deprecated", name: _lt("Deprecated (do not use)") }, triggers: ["on_create", "on_write"], }, ]; -function computeDerivedOptions(options, fields, recordValue) { +function computeDerivedOptions(options, fields, currentSelection, { excludeGroups = [] } = {}) { // filter options to display, derived from the current value and the model fields const derivedOptions = []; for (const [value, label] of options) { const { group, triggers } = OPT_GROUPS.find((g) => g.triggers.includes(value)); - if (group.key === "deprecated" && !triggers.includes(recordValue)) { + if ( + (group.key === "deprecated" && !triggers.includes(currentSelection)) || + excludeGroups.includes(group.key) + ) { // skip deprecated triggers if the current value is not deprecated continue; } const filterFn = TRIGGER_FILTERS[value]; - const triggerFields = fields.filter(filterFn); - if (triggerFields.length === 0) { - // skip triggers that don't have any corresponding field - continue; + if (filterFn) { + const triggerFields = fields.filter(filterFn); + if (triggerFields.length === 0) { + // skip triggers that don't have any corresponding field + continue; + } } const option = { group, value, label }; derivedOptions.push(option); @@ -82,7 +91,8 @@ export class TriggerSelectionField extends SelectionField { const derivedOptions = computeDerivedOptions( fields[this.props.name].selection, relatedModelFields, - data[this.props.name] + data[this.props.name], + { excludeGroups: data.model_is_mail_thread ? [] : ["mail"] } ); // then group and sort them @@ -105,5 +115,6 @@ export class TriggerSelectionField extends SelectionField { export const triggerSelectionField = { ...selectionField, component: TriggerSelectionField, + fieldDependencies: [{ name: "model_is_mail_thread", type: "boolean" }], }; registry.category("fields").add("base_automation_trigger_selection", triggerSelectionField); diff --git a/addons/test_base_automation/models/test_base_automation.py b/addons/test_base_automation/models/test_base_automation.py index ea7ebe7c12a..18e97818450 100644 --- a/addons/test_base_automation/models/test_base_automation.py +++ b/addons/test_base_automation/models/test_base_automation.py @@ -109,3 +109,8 @@ class Stage(models.Model): class Tag(models.Model): _name = _description = 'test_base_automation.tag' name = fields.Char() + +class LeadThread(models.Model): + _inherit = ["base.automation.lead.test", "mail.thread"] + _name = "base.automation.lead.thread.test" + _description = "Threaded Lead Test" diff --git a/addons/test_base_automation/static/tests/tour/base_automation_tour.js b/addons/test_base_automation/static/tests/tour/base_automation_tour.js index d93a6589af1..7e3f681b443 100644 --- a/addons/test_base_automation/static/tests/tour/base_automation_tour.js +++ b/addons/test_base_automation/static/tests/tour/base_automation_tour.js @@ -550,3 +550,56 @@ registry.category("web_tour.tours").add("test_form_view_custom_reference_field", }, ], }); + +registry.category("web_tour.tours").add("test_form_view_mail_triggers", { + test: true, + steps: () => [ + { + trigger: ".o_field_widget[name='model_id'] input", + run: "text base.automation.lead.test", + }, + { + trigger: + ".o_field_widget[name='model_id'] .dropdown-menu li a:contains(Automated Rule Test)", + }, + { + 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") + } + }, + { + trigger: ".o_field_widget[name='model_id'] input", + run: "text base.automation.lead.thread.test", + }, + { + trigger: + ".o_field_widget[name='model_id'] .dropdown-menu li a:contains(Threaded Lead Test)", + run(helpers) { + waitOrmCalls = observeOrmCalls(); + helpers.click(this.$anchor); + return nextTick(); + }, + }, + { + trigger: "body", + async run() { + await waitOrmCalls(); + await nextTick(); + }, + }, + { + 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") + } + }, + { + trigger: "button.o_form_button_cancel", + }, + { + trigger: "body:not(:has(button.o_form_button_cancel)", + run() {} + } + ], +}); diff --git a/addons/test_base_automation/tests/test_flow.py b/addons/test_base_automation/tests/test_flow.py index 48d3c1370b9..bc00a828d6e 100644 --- a/addons/test_base_automation/tests/test_flow.py +++ b/addons/test_base_automation/tests/test_flow.py @@ -4,9 +4,10 @@ from unittest.mock import patch import sys +from odoo.tools import mute_logger from odoo.addons.base.tests.common import TransactionCaseWithUserDemo from odoo.tests import common, tagged -from odoo.exceptions import AccessError +from odoo.exceptions import AccessError, ValidationError from odoo import Command @@ -959,3 +960,51 @@ class TestCompute(common.TransactionCase): task.unlink() finally: sys.setrecursionlimit(limit) + + def test_mail_triggers(self): + lead_model = self.env["ir.model"]._get("base.automation.lead.test") + with self.assertRaises(ValidationError): + create_automation(self, trigger="on_message_sent", model_id=lead_model.id) + + lead_thread_model = self.env["ir.model"]._get("base.automation.lead.thread.test") + automation = create_automation(self, trigger="on_message_sent", model_id=lead_thread_model.id, _actions={ + "state": "object_write", + "update_field_id": self.env["ir.model.fields"]._get("base.automation.lead.thread.test", "active").id, + "value": False + }) + + ext_partner = self.env["res.partner"].create({"name": "ext", "email": "email@server.com"}) + internal_partner = self.env["res.users"].browse(2).partner_id + + obj = self.env["base.automation.lead.thread.test"].create({"name": "test"}) + obj.message_subscribe([ext_partner.id, internal_partner.id]) + + obj.message_post(author_id=internal_partner.id, message_type="comment", subtype_xmlid="mail.mt_comment") + self.assertFalse(obj.active) + + obj.active = True + obj.message_post(author_id=internal_partner.id, subtype_xmlid="mail.mt_comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=ext_partner.id, message_type="comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=internal_partner.id, message_type="comment") + self.assertTrue(obj.active) + obj.message_post(author_id=internal_partner.id, subtype_xmlid="mail.mt_comment", message_type="comment") + self.assertFalse(obj.active) + + automation.trigger = "on_message_received" + obj.active = True + obj.message_post(author_id=internal_partner.id, subtype_xmlid="mail.mt_comment", message_type="comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=ext_partner.id, message_type="comment") + self.assertTrue(obj.active) + + obj.message_post(author_id=ext_partner.id, subtype_xmlid="mail.mt_comment", message_type="comment") + self.assertFalse(obj.active) + + obj.active = True + obj.message_post(author_id=ext_partner.id, subtype_xmlid="mail.mt_comment") + self.assertTrue(obj.active) diff --git a/addons/test_base_automation/tests/test_tour.py b/addons/test_base_automation/tests/test_tour.py index ed3e0028e31..369528c16f0 100644 --- a/addons/test_base_automation/tests/test_tour.py +++ b/addons/test_base_automation/tests/test_tour.py @@ -284,3 +284,12 @@ class BaseAutomationTestUi(HttpCase): "test_form_view_custom_reference_field", login="admin", ) + + def test_form_view_mail_triggers(self): + self.start_tour( + ( + f"/web?debug=0#{_urlencode_kwargs(action=self.env.ref('base_automation.base_automation_act').id, view_type='form')}" + ), + "test_form_view_mail_triggers", + login="admin", + )