diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py
index 5668c4ed0fa..ccf4863cdcb 100644
--- a/addons/account/wizard/account_invoice_send.py
+++ b/addons/account/wizard/account_invoice_send.py
@@ -144,9 +144,13 @@ class AccountInvoiceSend(models.TransientModel):
active_records = self.env[self.model].browse(active_ids)
langs = active_records.mapped('partner_id.lang')
default_lang = get_lang(self.env)
- for lang in (set(langs) or [default_lang]):
+ for lang in sorted((set(langs)) or [default_lang]):
active_ids_lang = active_records.filtered(lambda r: r.partner_id.lang == lang).ids
- self_lang = self.with_context(active_ids=active_ids_lang, lang=lang)
+ self_lang = self.with_context(
+ composer_force_res_ids=active_ids_lang,
+ active_ids=active_ids_lang,
+ lang=lang
+ )
self_lang.onchange_template_id()
self_lang._send_email()
else:
diff --git a/addons/l10n_generic_coa/tests/__init__.py b/addons/l10n_generic_coa/tests/__init__.py
new file mode 100644
index 00000000000..3ecb24ac29b
--- /dev/null
+++ b/addons/l10n_generic_coa/tests/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_mail_performance
diff --git a/addons/l10n_generic_coa/tests/test_mail_performance.py b/addons/l10n_generic_coa/tests/test_mail_performance.py
new file mode 100644
index 00000000000..716e3d8d491
--- /dev/null
+++ b/addons/l10n_generic_coa/tests/test_mail_performance.py
@@ -0,0 +1,440 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from ast import literal_eval
+
+from datetime import date
+
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+from odoo.addons.mail.tests.common import MailCommon
+from odoo.tests.common import Form, users, warmup
+from odoo.tests import tagged
+from odoo.tools import formataddr, mute_logger
+
+
+@tagged('mail_performance', 'account_performance', 'post_install_l10n', 'post_install', '-at_install')
+class BaseMailAccountPerformance(AccountTestInvoicingCommon, MailCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # ensure print params
+ cls.user_accountman = cls.env.user # main account setup shadows users, better save it
+ cls.company_main = cls.company_data['company']
+ cls.company_main.invoice_is_email = True
+ cls.company_main.invoice_is_print = False
+ cls.move_template = cls.env['mail.template'].create({
+ 'auto_delete': True,
+ 'body_html': '
TemplateBody for
',
+ 'description': 'Sent to customers with their invoices in attachment',
+ 'email_from': "{{ (object.invoice_user_id.email_formatted or user.email_formatted) }}",
+ 'mail_server_id': cls.mail_server_global.id,
+ 'model_id': cls.env['ir.model']._get_id('account.move'),
+ 'name': "Invoice: Test Sending",
+ 'partner_to': "{{ object.partner_id.id }}",
+ 'subject': "{{ object.company_id.name }} Invoice (Ref {{ object.name or 'n/a' }})",
+ 'report_template_ids': [(4, cls.env.ref('account.account_invoices').id)],
+ 'lang': "{{ object.partner_id.lang }}",
+ })
+ cls.attachments = cls.env['ir.attachment'].create(
+ cls._generate_attachments_data(
+ 2, cls.move_template._name, cls.move_template.id
+ )
+ )
+ cls.move_template.write({
+ 'attachment_ids': [(6, 0, cls.attachments.ids)]
+ })
+
+ # test users + fetch admin user for testing (recipient, ...)
+ cls.user_account = cls.env['res.users'].with_context(cls._test_context).create({
+ 'company_id': cls.company_main.id,
+ 'company_ids': [
+ (6, 0, (cls.company_data['company'] + cls.company_data_2['company']).ids)
+ ],
+ 'country_id': cls.env.ref('base.be').id,
+ 'email': 'e.e@example.com',
+ 'groups_id': [
+ (6, 0, [cls.env.ref('base.group_user').id,
+ cls.env.ref('account.group_account_invoice').id,
+ cls.env.ref('base.group_partner_manager').id
+ ])
+ ],
+ 'login': 'user_account',
+ 'name': 'Ernest Employee',
+ 'notification_type': 'inbox',
+ 'signature': '--\nErnest',
+ })
+ cls.user_account_other = cls.env['res.users'].with_context(cls._test_context).create({
+ 'company_id': cls.company_admin.id,
+ 'company_ids': [(4, cls.company_admin.id)],
+ 'country_id': cls.env.ref('base.be').id,
+ 'email': 'e.e.other@example.com',
+ 'groups_id': [
+ (6, 0, [cls.env.ref('base.group_user').id,
+ cls.env.ref('account.group_account_invoice').id,
+ cls.env.ref('base.group_partner_manager').id
+ ])
+ ],
+ 'login': 'user_account_other',
+ 'name': 'Eglantine Employee',
+ 'notification_type': 'inbox',
+ 'signature': '--\nEglantine',
+ })
+
+ # mass mode: 10 invoices with their customer
+ country_id = cls.env.ref('base.be').id
+ langs = ['en_US', 'es_ES']
+ cls.env['res.lang']._activate_lang('es_ES')
+ cls.test_customers = cls.env['res.partner'].create([
+ {'country_id': country_id,
+ 'email': f'test_partner_{idx}@test.example.com',
+ 'mobile': f'047500{idx:2d}{idx:2d}',
+ 'lang': langs[idx % len(langs)],
+ 'name': f'Partner_{idx}',
+ } for idx in range(0, 10)
+ ])
+ cls.test_account_moves = cls.env['account.move'].create([{
+ 'invoice_date': date(2022, 3, 2),
+ 'invoice_date_due': date(2022, 3, 10),
+ 'invoice_line_ids': [
+ (0, 0, {'name': 'Line1',
+ 'price_unit': 100.0
+ }
+ ),
+ (0, 0, {'name': 'Line2',
+ 'price_unit': 200.0
+ }
+ ),
+ ],
+ 'invoice_user_id': cls.user_account_other.id,
+ 'move_type': 'out_invoice',
+ 'name': f'INVOICE_{idx:02d}',
+ 'partner_id': cls.test_customers[idx].id,
+ } for idx in range(0, 10)])
+
+ # test impact of multi language support
+ cls._activate_multi_lang(
+ test_record=cls.test_account_moves,
+ test_template=cls.move_template,
+ )
+
+ def setUp(self):
+ super().setUp()
+
+ # setup mail gateway to simulate complete reply-to computation
+ self._init_mail_gateway()
+
+ # patch registry to simulate a ready environment
+ self.patch(self.env.registry, 'ready', True)
+ self.flush_tracking()
+
+
+@tagged('mail_performance', 'account_performance', 'post_install_l10n', 'post_install', '-at_install')
+class TestAccountComposerPerformance(BaseMailAccountPerformance):
+ """ Test performance of custom composer for moves. """
+
+ @users('user_account')
+ @warmup
+ @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
+ def test_move_composer_multi(self):
+ """ Test with mailing mode """
+ test_moves = self.test_account_moves.with_env(self.env)
+ test_customers = self.test_customers.with_env(self.env)
+ move_template = self.move_template.with_env(self.env)
+
+ for test_move in test_moves:
+ self.assertFalse(test_move.is_move_sent)
+
+ # QueryCount: 22
+ default_ctx = test_moves.action_send_and_print()['context']
+ default_ctx['default_template_id'] = move_template.id
+ composer_form = Form(
+ self.env['account.invoice.send'].with_context(default_ctx)
+ )
+ composer = composer_form.save()
+
+ # QueryCount: 356 on l10n staging (l10n_generic_coa: 252 / com 288 / ent 290)
+ with self.mock_mail_gateway(mail_unlink_sent=False):
+ composer.send_and_print_action()
+
+ # check results: emails (mailing mode when being in multi)
+ self.assertEqual(len(self._mails), 10, 'Should send an email to each invoice')
+ for move, customer in zip(test_moves, test_customers):
+ with self.subTest(move=move, customer=customer):
+ if move.partner_id.lang == 'es_ES':
+ _exp_body_tip = f'SpanishBody for {move.name}'
+ _exp_move_name = f'Factura borrador {move.name}'
+ _exp_report_name = f'Factura borrador {move.name}.html'
+ _exp_subject = f'SpanishSubject for {move.name}'
+ else:
+ _exp_body_tip = f'TemplateBody for {move.name}'
+ _exp_move_name = move.display_name
+ _exp_report_name = f'Draft Invoice {move.name}.html'
+ _exp_subject = f'{self.env.user.company_id.name} Invoice (Ref {move.name})'
+
+ self.assertEqual(move.partner_id, customer)
+ self.assertMailMail(
+ customer,
+ 'sent',
+ author=self.user_account.partner_id, # author: current user, not synchronized with email_from of template
+ content=_exp_body_tip,
+ email_values={
+ 'attachments_info': [
+ {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'},
+ {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'},
+ {'name': _exp_report_name, 'type': 'text/plain'},
+ ],
+ 'body_content': _exp_body_tip,
+ 'email_from': self.user_account_other.email_formatted,
+ 'subject': _exp_subject,
+ 'reply_to': formataddr((
+ f'{move.company_id.name} {_exp_move_name}',
+ f'{self.alias_catchall}@{self.alias_domain}'
+ )),
+ },
+ fields_values={
+ 'auto_delete': True,
+ 'email_from': self.user_account_other.email_formatted,
+ 'is_notification': True, # should keep logs by default
+ 'mail_server_id': self.mail_server_global,
+ 'subject': _exp_subject,
+ 'reply_to': formataddr((
+ f'{move.company_id.name} {_exp_move_name}',
+ f'{self.alias_catchall}@{self.alias_domain}'
+ )),
+ },
+ )
+
+ # composer configuration
+ self.assertEqual(composer.attachment_ids, self.attachments)
+ # self.assertEqual(composer.body, move_template.body_html)
+ self.assertEqual(composer.body, 'SpanishBody for
',
+ 'TODO: currently composer content is forced with template content using last browsed lang')
+ self.assertEqual(composer.composition_mode, 'mass_mail')
+ self.assertEqual(composer.invoice_ids, test_moves)
+ self.assertTrue(composer.is_email)
+ self.assertFalse(composer.is_print)
+ self.assertEqual(composer.mail_server_id, self.mail_server_global)
+ self.assertEqual(composer.model, test_moves._name)
+ self.assertEqual(
+ sorted(literal_eval(composer.res_ids)),
+ test_moves.ids
+ )
+ # self.assertEqual(composer.subject, move_template.subject)
+ self.assertEqual(composer.subject, 'SpanishSubject for {{ object.name }}',
+ 'TODO: currently composer content is forced with template content using last browsed lang')
+ self.assertEqual(composer.template_id, move_template)
+ # invoice update
+ for test_move in test_moves:
+ self.assertTrue(test_move.is_move_sent)
+
+ @users('user_account')
+ @warmup
+ @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
+ def test_move_composer_single(self):
+ """ Test comment mode """
+ test_move = self.test_account_moves[0].with_env(self.env)
+ test_customer = self.test_customers[0].with_env(self.env)
+ move_template = self.move_template.with_env(self.env)
+
+ # QueryCount: 70 on l10n staging (l10n_generic_coa: 53 / com 55)
+ default_ctx = test_move.action_send_and_print()['context']
+ default_ctx['default_template_id'] = move_template.id
+ composer_form = Form(
+ self.env['account.invoice.send'].with_context(default_ctx)
+ )
+ composer = composer_form.save()
+
+ # QueryCount: 100 on l10n staging (l10n_generic_coa: 92 / com 103)
+ with self.mock_mail_gateway(mail_unlink_sent=False), \
+ self.mock_mail_app():
+ composer = composer_form.save()
+ composer.send_and_print_action()
+ self.env.cr.flush() # force tracking message
+
+ # check results: comment (post when being in single mode)
+ self.assertEqual(len(self._new_msgs), 2, 'Should produce 2 messages: one for posting template, one for tracking')
+ print_msg, track_msg = self._new_msgs[0], self._new_msgs[1]
+ self.assertNotified(
+ print_msg,
+ [{
+ 'is_read': True,
+ 'partner': test_customer,
+ 'type': 'email',
+ }],
+ )
+
+ # print: template-based message
+ self.assertEqual(len(print_msg.attachment_ids), 3)
+ self.assertNotIn(self.attachments, print_msg.attachment_ids,
+ 'Attachments should be duplicated, not just linked')
+ self.assertEqual(print_msg.author_id, self.env.user.partner_id,
+ 'TODO: not synchronized with email_from choice')
+ # self.assertEqual(print_msg.author_id, self.user_account_other.partner_id,
+ # 'Should take invoice_user_id partner')
+ self.assertEqual(print_msg.email_from, self.user_account_other.email_formatted,
+ 'Should take invoice_user_id email')
+ self.assertEqual(print_msg.notified_partner_ids, test_customer + self.user_accountman.partner_id)
+ self.assertEqual(print_msg.subject, f'{self.env.user.company_id.name} Invoice (Ref {test_move.name})')
+ # tracking: is_move_sent
+ self.assertEqual(track_msg.author_id, self.env.user.partner_id)
+ self.assertEqual(track_msg.email_from, self.env.user.email_formatted)
+ self.assertEqual(track_msg.tracking_value_ids.field.name, 'is_move_sent')
+ # sent email
+ self.assertMailMail(
+ test_customer,
+ 'sent',
+ author=self.user_account.partner_id, # author: current user, not synchronized with email_from of template
+ content=f'TemplateBody for {test_move.name}',
+ email_values={
+ 'attachments_info': [
+ {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'},
+ {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'},
+ {'name': f'Draft Invoice {test_move.name}.html', 'type': 'text/plain'},
+ ],
+ 'body_content': f'TemplateBody for {test_move.name}',
+ 'email_from': self.user_account_other.email_formatted,
+ 'subject': f'{self.env.user.company_id.name} Invoice (Ref {test_move.name})',
+ 'reply_to': formataddr((
+ f'{test_move.company_id.name} {test_move.display_name}',
+ f'{self.alias_catchall}@{self.alias_domain}'
+ )),
+ },
+ fields_values={
+ 'auto_delete': False,
+ 'email_from': self.user_account_other.email_formatted,
+ 'is_notification': True, # should keep logs by default
+ 'mail_server_id': self.mail_server_global,
+ 'subject': f'{self.env.user.company_id.name} Invoice (Ref {test_move.name})',
+ 'reply_to': formataddr((
+ f'{test_move.company_id.name} {test_move.display_name}',
+ f'{self.alias_catchall}@{self.alias_domain}'
+ )),
+ },
+ )
+
+ # composer configuration
+ self.assertEqual(len(composer.attachment_ids), 3)
+ self.assertNotIn(self.attachments, composer.attachment_ids,
+ 'Attachments should be duplicated, not just linked')
+ self.assertIn(f'TemplateBody for {test_move.name}', composer.body)
+ self.assertEqual(composer.composition_mode, 'comment')
+ self.assertEqual(composer.invoice_ids, test_move)
+ self.assertTrue(composer.is_email)
+ self.assertFalse(composer.is_print)
+ self.assertEqual(composer.model, test_move._name)
+ self.assertEqual(
+ sorted(literal_eval(composer.res_ids)),
+ test_move.ids
+ )
+ self.assertEqual(composer.subject, f'{self.env.user.company_id.name} Invoice (Ref {test_move.name})')
+ self.assertEqual(composer.template_id, move_template)
+ # invoice update
+ self.assertTrue(test_move.is_move_sent)
+
+ @users('user_account')
+ @warmup
+ @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
+ def test_move_composer_single_lang(self):
+ """ Test with another language """
+ test_move = self.test_account_moves[1].with_env(self.env)
+ test_customer = self.test_customers[1].with_env(self.env)
+ move_template = self.move_template.with_env(self.env)
+
+ # QueryCount: 71 on l10n staging (l10n_generic_coa: 54 / com 56)
+ default_ctx = test_move.action_send_and_print()['context']
+ default_ctx['default_template_id'] = move_template.id
+ composer_form = Form(
+ self.env['account.invoice.send'].with_context(default_ctx)
+ )
+ composer = composer_form.save()
+
+ # QueryCount: 101 on l10n staging (l10n_generic_coa: 92 / com 103)
+ with self.mock_mail_gateway(mail_unlink_sent=False), \
+ self.mock_mail_app():
+ composer = composer_form.save()
+ composer.send_and_print_action()
+ self.env.cr.flush() # force tracking message
+
+ # check results: comment (post when being in single mode)
+ self.assertEqual(len(self._new_msgs), 2, 'Should produce 2 messages: one for posting template, one for tracking')
+ print_msg, track_msg = self._new_msgs[0], self._new_msgs[1]
+ self.assertNotified(
+ print_msg,
+ [{
+ 'is_read': True,
+ 'partner': test_customer,
+ 'type': 'email',
+ }],
+ )
+
+ # print: template-based message
+ self.assertEqual(len(print_msg.attachment_ids), 3)
+ self.assertNotIn(self.attachments, print_msg.attachment_ids,
+ 'Attachments should be duplicated, not just linked')
+ self.assertEqual(print_msg.author_id, self.env.user.partner_id,
+ 'TODO: not synchronized with email_from choice')
+ # self.assertEqual(print_msg.author_id, self.user_account_other.partner_id,
+ # 'Should take invoice_user_id partner')
+ self.assertEqual(print_msg.email_from, self.user_account_other.email_formatted,
+ 'Should take invoice_user_id email')
+ self.assertEqual(print_msg.notified_partner_ids, test_customer + self.user_accountman.partner_id)
+ self.assertEqual(print_msg.subject, f'SpanishSubject for {test_move.name}')
+ # tracking: is_move_sent
+ self.assertEqual(track_msg.author_id, self.env.user.partner_id)
+ self.assertEqual(track_msg.email_from, self.env.user.email_formatted)
+ self.assertEqual(track_msg.tracking_value_ids.field.name, 'is_move_sent')
+ # sent email
+ self.assertMailMail(
+ test_customer,
+ 'sent',
+ author=self.user_account.partner_id, # author: current user, not synchronized with email_from of template
+ content=f'SpanishBody for {test_move.name}', # translated version
+ email_values={
+ 'attachments_info': [
+ {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'},
+ {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'},
+ {'name': f'Factura borrador {test_move.name}.html', 'type': 'text/plain'},
+ ],
+ 'body_content': f'SpanishBody for {test_move.name}', # translated version
+ 'email_from': self.user_account_other.email_formatted,
+ 'subject': f'SpanishSubject for {test_move.name}', # translated version
+ 'reply_to': formataddr((
+ f'{test_move.company_id.name} {test_move.display_name}',
+ f'{self.alias_catchall}@{self.alias_domain}'
+ )),
+ },
+ fields_values={
+ 'auto_delete': False,
+ 'email_from': self.user_account_other.email_formatted,
+ 'is_notification': True, # should keep logs by default
+ 'mail_server_id': self.mail_server_global,
+ 'subject': f'SpanishSubject for {test_move.name}', # translated version
+ 'reply_to': formataddr((
+ f'{test_move.company_id.name} {test_move.display_name}',
+ f'{self.alias_catchall}@{self.alias_domain}'
+ )),
+ },
+ )
+
+ # composer configuration
+ self.assertEqual(len(composer.attachment_ids), 3)
+ self.assertNotIn(self.attachments, composer.attachment_ids,
+ 'Attachments should be duplicated, not just linked')
+ self.assertIn(f'SpanishBody for {test_move.name}', composer.body,
+ 'Should be translated, based on template')
+ self.assertEqual(composer.composition_mode, 'comment')
+ self.assertEqual(composer.invoice_ids, test_move)
+ self.assertTrue(composer.is_email)
+ self.assertFalse(composer.is_print)
+ self.assertEqual(composer.model, test_move._name)
+ self.assertEqual(
+ sorted(literal_eval(composer.res_ids)),
+ test_move.ids
+ )
+ self.assertEqual(composer.subject, f'SpanishSubject for {test_move.name}',
+ 'Should be translated, based on template')
+ self.assertEqual(composer.template_id, move_template)
+ # invoice update
+ self.assertTrue(test_move.is_move_sent)
diff --git a/addons/mail/tests/common.py b/addons/mail/tests/common.py
index e9a4ae5ec1f..eaa5d21bb26 100644
--- a/addons/mail/tests/common.py
+++ b/addons/mail/tests/common.py
@@ -567,7 +567,11 @@ class MockEmail(common.BaseCase, MockSmtplibCase):
if 'attachments_info' in expected:
attachments = sent_mail['attachments']
for attachment_info in expected['attachments_info']:
- attachment = next(attach for attach in attachments if attach[0] == attachment_info['name'])
+ attachment = next((attach for attach in attachments if attach[0] == attachment_info['name']), False)
+ self.assertTrue(
+ bool(attachment),
+ f'Attachment {attachment_info["name"]} not found in attachments',
+ )
if attachment_info.get('raw'):
self.assertEqual(attachment[1], attachment_info['raw'])
if attachment_info.get('type'):
@@ -907,9 +911,10 @@ class MailCase(MockEmail):
if not self.mail_unlink_sent:
self.assertMailMail(
partners, mail_status,
- author=message.author_id if message.author_id else message.email_from,
+ author=message_info.get('fields_values', {}).get('author_id') or message.author_id or message.email_from,
mail_message=message,
email_values=email_values,
+ fields_values=message_info.get('fields_values'),
)
else:
for recipient in partners:
diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py
index 09b1833533a..4f9f3ca0139 100644
--- a/addons/mail/wizard/mail_compose_message.py
+++ b/addons/mail/wizard/mail_compose_message.py
@@ -1060,10 +1060,16 @@ class MailComposer(models.TransientModel):
Note that 'active_ids' context key is supported at this point as mailing
on big ID list would create issues if stored in database.
+ Another context key 'composer_force_res_ids' is temporarily supported
+ to ease support of accounting wizard, while waiting to implement a
+ proper solution to language management.
+
:return: a list of IDs (empty list in case of falsy strings)"""
self.ensure_one()
return self._parse_res_ids(
- self.res_ids or self.env.context.get('active_ids')
+ self.env.context.get('composer_force_res_ids') or
+ self.res_ids or
+ self.env.context.get('active_ids')
) or []
@api.model