[IMP] mail: add a post server action

Currently only an email action based on template exists in server actions when
having mail app installed. It basically sends an email based on a mail template.

However being able to post a message on record is also useful. Instead of
sending emails it post on a document as a comment or as a note, like what
users can do using the chatter. Notification flow for those cases is the
classic from post: followers, specified partners, Inbox/Email, ...

Task-2613245 (Server actions mail update / cleaning)
Closes #45640

Part-of: odoo/odoo#75906
This commit is contained in:
Thibault Delavallée
2022-08-12 23:37:03 +02:00
parent 138ab8ef14
commit b3fc41cc44
9 changed files with 168 additions and 28 deletions
@@ -104,7 +104,7 @@ class BaseAutomation(models.Model):
raise exceptions.ValidationError(
_('Form Modification based actions can only be used with code action type.')
)
if any(action.trigger == 'on_unlink' and action.state in ['email', 'followers', 'next_activity'] for action in self):
if any(action.trigger == 'on_unlink' and action.state in ['mail_post', 'followers', 'next_activity'] for action in self):
raise exceptions.ValidationError(
_('Email, followers or activities action types cannot be used when deleting records.')
)
+69 -14
View File
@@ -8,27 +8,42 @@ from odoo.exceptions import ValidationError
class ServerActions(models.Model):
""" Add email option in server actions. """
""" Add mail.thread related options in server actions. """
_name = 'ir.actions.server'
_description = 'Server Action'
_inherit = ['ir.actions.server']
state = fields.Selection(selection_add=[
('email', 'Send Email'),
('followers', 'Add Followers'),
('next_activity', 'Create Next Activity'),
], ondelete={'email': 'cascade', 'followers': 'cascade', 'next_activity': 'cascade'})
state = fields.Selection(
selection_add=[('mail_post', 'Send Email'),
('followers', 'Add Followers'),
('next_activity', 'Create Next Activity'),
],
ondelete={'mail_post': 'cascade',
'followers': 'cascade',
'next_activity': 'cascade',
}
)
# Followers
partner_ids = fields.Many2many(
'res.partner', string='Add Followers',
compute='_compute_partner_ids', readonly=False, store=True)
# Template
# Message Post / Email
template_id = fields.Many2one(
'mail.template', 'Email Template',
domain="[('model_id', '=', model_id)]",
compute='_compute_template_id',
ondelete='set null', readonly=False, store=True,
)
# Message post
mail_post_autofollow = fields.Boolean(
'Subscribe Recipients', compute='_compute_mail_post_autofollow',
readonly=False, store=True)
mail_post_method = fields.Selection(
selection=[('email', 'Email'), ('comment', 'Post as Message'), ('note', 'Post as Note')],
string='Send as',
compute='_compute_mail_post_method',
readonly=False, store=True,
help='Choose method for email sending:\nEMail: send directly emails\nPost as Message: post on document and notify followers\nPost as Note: log a note on document')
# Next Activity
activity_type_id = fields.Many2one(
'mail.activity.type', string='Activity',
@@ -65,19 +80,37 @@ class ServerActions(models.Model):
@api.depends('model_id', 'state')
def _compute_template_id(self):
to_reset = self.filtered(
lambda act: act.state != 'email' or \
lambda act: act.state != 'mail_post' or \
(act.model_id != act.template_id.model_id)
)
if to_reset:
to_reset.template_id = False
@api.depends('state', 'mail_post_method')
def _compute_mail_post_autofollow(self):
to_reset = self.filtered(lambda act: act.state != 'mail_post' or act.mail_post_method == 'email')
if to_reset:
to_reset.mail_post_autofollow = False
other = self - to_reset
if other:
other.mail_post_autofollow = True
@api.depends('state')
def _compute_mail_post_method(self):
to_reset = self.filtered(lambda act: act.state != 'mail_post')
if to_reset:
to_reset.mail_post_method = False
other = self - to_reset
if other:
other.mail_post_method = 'email'
@api.depends('state')
def _compute_partner_ids(self):
to_reset = self.filtered(lambda act: act.state != 'followers')
if to_reset:
to_reset.partner_ids = False
@api.depends('model_id')
@api.depends('model_id', 'state')
def _compute_activity_type_id(self):
to_reset = self.filtered(
lambda act: act.state != 'next_activity' or \
@@ -106,7 +139,6 @@ class ServerActions(models.Model):
if not activity.activity_user_field_name:
activity.activity_user_field_name = 'user_id'
@api.constrains('activity_date_deadline_range')
def _check_activity_date_deadline_range(self):
if any(action.activity_date_deadline_range < 0 for action in self):
@@ -155,17 +187,40 @@ class ServerActions(models.Model):
return True
return False
def _run_action_email(self, eval_context=None):
def _run_action_mail_post_multi(self, eval_context=None):
# TDE CLEANME: when going to new api with server action, remove action
if not self.template_id or not self._context.get('active_id') or self._is_recompute():
if not self.template_id or (not self._context.get('active_ids') and not self._context.get('active_id')) or self._is_recompute():
return False
res_ids = self._context.get('active_ids', [self._context.get('active_id')])
# Clean context from default_type to avoid making attachment
# with wrong values in subsequent operations
cleaned_ctx = dict(self.env.context)
cleaned_ctx.pop('default_type', None)
cleaned_ctx.pop('default_parent_id', None)
self.template_id.with_context(cleaned_ctx).send_mail(self._context.get('active_id'), force_send=False,
raise_exception=False)
cleaned_ctx['mail_create_nosubscribe'] = True # do not subscribe random people to records
cleaned_ctx['mail_post_autofollow'] = self.mail_post_autofollow
if self.mail_post_method in ('comment', 'note'):
records = self.env[self.model_name].with_context(cleaned_ctx).browse(res_ids)
if self.mail_post_method == 'comment':
subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
else:
subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')
for record in records:
record.message_post_with_template(
self.template_id.id,
composition_mode='comment',
subtype_id=subtype_id,
)
else:
template = self.template_id.with_context(cleaned_ctx)
for res_id in res_ids:
template.send_mail(
res_id,
force_send=False,
raise_exception=False
)
return False
def _run_action_next_activity(self, eval_context=None):
+1 -1
View File
@@ -272,7 +272,7 @@ class MailTemplate(models.Model):
Attachment = self.env['ir.attachment'] # TDE FIXME: should remove default_type from context
# create a mail_mail based on values, without attachments
values = self.generate_email(res_id, ['subject', 'body_html', 'email_from', 'email_to', 'partner_to', 'email_cc', 'reply_to', 'scheduled_date'])
values = self.generate_email(res_id, ['auto_delete', 'subject', 'body_html', 'email_from', 'email_to', 'partner_to', 'email_cc', 'reply_to', 'scheduled_date'])
values['recipient_ids'] = [Command.link(pid) for pid in values.get('partner_ids', list())]
values['attachment_ids'] = [Command.link(aid) for aid in values.get('attachment_ids', list())]
values.update(email_values or {})
+1
View File
@@ -819,6 +819,7 @@ class MailCase(MockEmail):
mail_groups['failure'].append(partner)
if ncheck_send:
email_groups[ngroup].append(partner)
# canceled: currently nothing checked
elif nstatus == 'canceled':
pass
else:
@@ -40,10 +40,14 @@
</xpath>
<xpath expr="//field[@name='link_field_id']" position="after">
<field name="partner_ids" widget="many2many_tags"
attrs="{'invisible': [('state', '!=', 'followers')]}"/>
attrs="{'invisible': [('state', '!=', 'followers')]}"/>
<field name="template_id"
attrs="{'invisible': [('state', '!=', 'email')],
'required': [('state', '=', 'email')]}"/>
attrs="{'invisible': [('state', '!=', 'mail_post')],
'required': [('state', '=', 'mail_post')]}"/>
<field name="mail_post_method"
attrs="{'invisible': [('state', '!=', 'mail_post')],
'required': [('state', '=', 'mail_post')]}"/>
<field name="mail_post_autofollow" invisible="1"/>
</xpath>
</field>
</record>
@@ -89,10 +89,11 @@ record['name'] = record.name + 'X'""",
'active': True,
}, {
'name': 'Base Automation: test send an email',
'mail_post_method': 'email',
'model_id': self.env.ref('test_base_automation.model_base_automation_lead_test').id,
'template_id': self.test_mail_template_automation.id,
'trigger_field_ids': [(4, self.env.ref('test_base_automation.field_base_automation_lead_test__deadline').id)],
'state': 'email',
'state': 'mail_post',
'code': """
record = model.browse(env.context['active_id'])
record['name'] = record.name + 'X'""",
+81 -4
View File
@@ -3,19 +3,53 @@
from odoo.addons.base.tests.test_ir_actions import TestServerActionsBase
from odoo.addons.test_mail.tests.common import TestMailCommon
from odoo.tests import tagged
from odoo.tools import mute_logger
@tagged('ir_actions')
class TestServerActionsEmail(TestMailCommon, TestServerActionsBase):
def setUp(self):
super(TestServerActionsEmail, self).setUp()
self.template = self._create_template(
'res.partner',
{'email_from': '{{ object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted }}',
'partner_to': '%s' % self.test_partner.id,
}
)
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
def test_action_email(self):
email_template = self._create_template('res.partner', {'partner_to': '%s' % self.test_partner.id})
self.action.write({'state': 'email', 'template_id': email_template.id})
self.action.with_context(self.context).run()
# initial state
self.assertEqual(len(self.test_partner.message_ids), 1,
'Contains Contact created message')
self.assertFalse(self.test_partner.message_partner_ids)
# update action: send an email
self.action.write({
'mail_post_method': 'email',
'state': 'mail_post',
'template_id': self.template.id,
})
self.assertFalse(self.action.mail_post_autofollow, 'Email action does not support autofollow')
with self.mock_mail_app():
self.action.with_context(self.context).run()
# check an email is waiting for sending
mail = self.env['mail.mail'].sudo().search([('subject', '=', 'About TestingPartner')])
self.assertEqual(len(mail), 1)
# check email content
self.assertTrue(mail.auto_delete)
self.assertEqual(mail.body, '<p>Hello TestingPartner</p>')
self.assertFalse(mail.is_notification)
with self.mock_mail_gateway(mail_unlink_sent=True):
mail.send()
# no archive (message)
self.assertEqual(len(self.test_partner.message_ids), 1,
'Contains Contact created message')
self.assertFalse(self.test_partner.message_partner_ids)
def test_action_followers(self):
self.test_partner.message_unsubscribe(self.test_partner.message_partner_ids.ids)
@@ -27,6 +61,49 @@ class TestServerActionsEmail(TestMailCommon, TestServerActionsBase):
self.action.with_context(self.context).run()
self.assertEqual(self.test_partner.message_partner_ids, self.env.ref('base.partner_admin') | random_partner)
def test_action_message_post(self):
# initial state
self.assertEqual(len(self.test_partner.message_ids), 1,
'Contains Contact created message')
self.assertFalse(self.test_partner.message_partner_ids)
# test without autofollow and comment
self.action.write({
'mail_post_autofollow': False,
'mail_post_method': 'comment',
'state': 'mail_post',
'template_id': self.template.id
})
with self.assertSinglePostNotifications(
[{'partner': self.test_partner, 'type': 'email', 'status': 'ready'}],
message_info={'content': 'Hello %s' % self.test_partner.name,
'message_type': 'notification',
'subtype': 'mail.mt_comment',
}
):
self.action.with_context(self.context).run()
# NOTE: template using current user will have funny email_from
self.assertEqual(self.test_partner.message_ids[0].email_from, self.partner_root.email_formatted)
self.assertFalse(self.test_partner.message_partner_ids)
# test with autofollow and note
self.action.write({
'mail_post_autofollow': True,
'mail_post_method': 'note'
})
with self.assertSinglePostNotifications(
[{'partner': self.test_partner, 'type': 'email', 'status': 'ready'}],
message_info={'content': 'Hello %s' % self.test_partner.name,
'message_type': 'notification',
'subtype': 'mail.mt_note',
}
):
self.action.with_context(self.context).run()
self.assertEqual(len(self.test_partner.message_ids), 3,
'2 new messages produced')
self.assertEqual(self.test_partner.message_partner_ids, self.test_partner)
def test_action_next_activity(self):
self.action.write({
'state': 'next_activity',
+4 -3
View File
@@ -415,12 +415,13 @@ class IrActionsServer(models.Model):
default='object_write', required=True, copy=True,
help="Type of server action. The following values are available:\n"
"- 'Execute Python Code': a block of python code that will be executed\n"
"- 'Create': create a new record with new values\n"
"- 'Create a new Record': create a new record with new values\n"
"- 'Update a Record': update the values of a record\n"
"- 'Execute several actions': define an action that triggers several other server actions\n"
"- 'Send Email': automatically send an email (Discuss)\n"
"- 'Send Email': post a message, a note or send an email (Discuss)\n"
"- 'Add Followers': add followers to a record (Discuss)\n"
"- 'Create Next Activity': create an activity (Discuss)")
"- 'Create Next Activity': create an activity (Discuss)\n"
"- 'Send SMS Text Message': send SMS, log them on documents (SMS)")
# Generic
sequence = fields.Integer(default=5,
help="When dealing with multiple actions, the execution order is "
+2 -1
View File
@@ -23,9 +23,10 @@ class TestServerActionsBase(common.TransactionCase):
'address_format': 'SuperFormat',
})
self.test_partner = self.env['res.partner'].create({
'name': 'TestingPartner',
'city': 'OrigCity',
'country_id': self.test_country.id,
'email': 'test.partner@test.example.com',
'name': 'TestingPartner',
})
self.context = {
'active_model': 'res.partner',