[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
This commit is contained in:
Lucas Perais
2023-10-20 09:35:12 +00:00
parent ff917f5478
commit 542b455561
6 changed files with 178 additions and 13 deletions
@@ -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:
@@ -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);
@@ -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"
@@ -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() {}
}
],
});
+50 -1
View File
@@ -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)
@@ -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",
)