diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index e69e4491f8c..279631b4403 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -3012,36 +3012,9 @@ class MailThread(models.AbstractModel): if not partners_data: return True - lang = force_email_lang if force_email_lang else self.env.context.get('lang') - - if not model_description: - model_description = self.with_context(lang=lang)._get_model_description( - msg_vals['model'] if msg_vals and msg_vals.get('model') else message.model - ) - recipients_groups_list = self.with_context(lang=lang)._notify_get_recipients_classify( + base_mail_values = self._notify_by_email_get_base_mail_values( message, - partners_data, - model_description, - msg_vals=msg_vals, - ) - - if not recipients_groups_list: - return True - force_send = self.env.context.get('mail_notify_force_send', force_send) - - template_values = self.with_context(lang=lang)._notify_by_email_prepare_rendering_context( - message, msg_vals=msg_vals, model_description=model_description, - force_email_company=force_email_company, - force_email_lang=force_email_lang, - ) # 10 queries - if subtitles: - template_values['subtitles'] = subtitles - - email_layout_xmlid = msg_vals.get('email_layout_xmlid') if msg_vals else message.email_layout_xmlid - template_xmlid = email_layout_xmlid if email_layout_xmlid else 'mail.mail_notification_layout' - base_mail_values = self.with_context(lang=lang)._notify_by_email_get_base_mail_values( - message, - additional_values={'auto_delete': mail_auto_delete}, + additional_values={'auto_delete': mail_auto_delete} ) # Clean the context to get rid of residual default_* keys that could cause issues during @@ -3057,18 +3030,23 @@ class MailThread(models.AbstractModel): # loop on groups (customer, portal, user, ... + model specific like group_sale_salesman) notif_create_values = [] recipients_max = 50 - for recipients_group in recipients_groups_list: + for _lang, render_values, recipients_group in self._notify_get_classified_recipients_iterator( + message, + partners_data, + msg_vals=msg_vals, + model_description=model_description, + force_email_company=force_email_company, + force_email_lang=force_email_lang, + subtitles=subtitles, + ): # generate notification email content + mail_body = self._notify_by_email_render_layout( + message, + recipients_group, + msg_vals=msg_vals, + render_values=render_values, + ) recipients_ids = recipients_group.pop('recipients') - render_values = {**template_values, **recipients_group} - # {company, is_discussion, lang, message, model_description, record, record_name, signature, subtype, tracking_values, website_url} - # {actions, button_access, has_button_access, recipients} - - mail_body = self.env['ir.qweb']._render(template_xmlid, render_values, minimal_qcontext=True, raise_if_not_found=False, lang=template_values['lang']) - if not mail_body: - _logger.warning('QWeb template %s not found or is empty when sending notification emails. Sending without layouting.', template_xmlid) - mail_body = message.body - mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body) # create email for recipients_ids_chunk in split_every(recipients_max, recipients_ids): @@ -3113,6 +3091,7 @@ class MailThread(models.AbstractModel): # to prevent sending email during a simple update of the database # using the command-line. test_mode = getattr(threading.current_thread(), 'testing', False) + force_send = self.env.context.get('mail_notify_force_send', force_send) if force_send and len(emails) < recipients_max and (not self.pool._init or test_mode): # unless asked specifically, send emails after the transaction to # avoid side effects due to emails being sent while the transaction fails @@ -3132,6 +3111,86 @@ class MailThread(models.AbstractModel): return True + def _notify_get_classified_recipients_iterator( + self, message, recipients_data, msg_vals=False, + model_description=False, force_email_company=False, force_email_lang=False, # rendering + subtitles=None): + """ Make groups of recipients, based on 'recipients_data' which is a list + of recipients informations. Purpose of this method is to group them by + main usage ('user', 'portal_user', 'follower', 'customer', ... see + @_notify_get_recipients_classify) and lang. Each group is linked to + an evaluation context to render the notification layout. + + :param message: ``mail.message`` record to notify; + :param list recipients_data: see ``MailThread._notify_get_recipients``; + :param msg_vals: dictionary of values used to create the message. If + given it may be used to access values related to ``message``; + + :param str model_description: description of current model, given to + avoid fetching it and easing translation support; + :param record force_email_company: record used when rendering + notification layout. Otherwise computed based on current record; + :param str force_email_lang: when no specific lang is found this is the + default lang to use notably to compute model name or translate access + buttons; + :param list subtitles: optional list set as template value "subtitles"; + + :return: iterator based on recipients classified by lang, with their + rendering evaluation context. Each item is a tuple containing ( + lang: used for rendering (customer language, forced email, default + environment language, + render_values: used to render the notification layout and translated + using lang, + recipients_group: a recipients group is a dict containing data + defined in "_notify_get_recipients_groups" like { + 'active': if not, it is skipped in notification process (ease + inheritance to be already present); + 'actions': list of actions to display as links or buttons in form + {'url': link of the action, 'title': link or button + string}; + 'button_access': main access document button information, {'url' + link of the access, 'title': link or button + string}; + 'has_button_access': display access document main button in email; + 'notification_group_name': name of the group, to ease usage; + 'recipients': list of partner IDs, will be fillup when evaluating + groups; + } + ); + """ + lang_to_recipients = {} + for data in recipients_data: + lang_to_recipients.setdefault( + data.get('lang') or force_email_lang or self.env.lang, + [], + ).append(data) + + for lang, lang_recipients_data in lang_to_recipients.items(): + record_wlang = self.with_context(lang=lang) + lang_model_description = model_description + if not lang_model_description: + lang_model_description = record_wlang._get_model_description( + msg_vals['model'] if msg_vals and msg_vals.get('model') else message.model + ) + recipients_groups_list = record_wlang._notify_get_recipients_classify( + message, + lang_recipients_data, + lang_model_description, + msg_vals=msg_vals, + ) + render_values = record_wlang._notify_by_email_prepare_rendering_context( + message, + msg_vals=msg_vals, + model_description=lang_model_description, + force_email_company=force_email_company, + force_email_lang=lang, + ) # 10 queries + if subtitles: + render_values['subtitles'] = subtitles + + for recipients_group in recipients_groups_list: + yield (lang, render_values, recipients_group) + def _notify_by_email_prepare_rendering_context(self, message, msg_vals=False, model_description=False, force_email_company=False, @@ -3168,7 +3227,7 @@ class MailThread(models.AbstractModel): """ if msg_vals is False: msg_vals = {} - lang = force_email_lang if force_email_lang else self.env.context.get('lang') + lang = force_email_lang if force_email_lang else self.env.lang record_wlang = self.with_context(lang=lang) # compute send user and its related signature; try to use self.env.user instead of browsing @@ -3235,6 +3294,47 @@ class MailThread(models.AbstractModel): 'is_html_empty': is_html_empty, } + def _notify_by_email_render_layout(self, message, recipients_group, + msg_vals=False, + render_values=None): + """ Renders the email layout for a given recipients group which + encapsulate the message body. + + :param record message: record being notified. May be + void as 'msg_vals' superseeds it; + :param dict recipients_group: a dict containing data for the recipients, + see @ _notify_get_recipients_groups; + :param dict msg_vals: values dict used to create the message, allows to + skip message usage and spare some queries; + :param dict render_values: values to render the notification layout; + + At this point expected values are + render_values: company, is_discussion, lang, message, model_description, + record, record_name, signature, subtype, tracking_values, + website_url + recipients_group: actions, button_access, has_button_access, recipients + + :return str: rendered complete layout; + """ + if render_values is None: + render_values = {} + + email_layout_xmlid = msg_vals.get('email_layout_xmlid') if msg_vals else message.email_layout_xmlid + template_xmlid = email_layout_xmlid if email_layout_xmlid else 'mail.mail_notification_layout' + + render_values = {**render_values, **recipients_group} + mail_body = self.env['ir.qweb']._render( + template_xmlid, + render_values, + minimal_qcontext=True, + raise_if_not_found=False, + lang=render_values.get('lang', self.env.lang), + ) + if not mail_body: + _logger.warning('QWeb template %s not found or is empty when sending notification emails. Sending without layouting.', template_xmlid) + mail_body = message.body + return mail_body + def _notify_by_email_get_base_mail_values(self, message, additional_values=None): """ Return model-specific and message-related values to be used when creating notification emails. It serves as a common basis for all diff --git a/addons/mail/models/models.py b/addons/mail/models/models.py index 33ffde8786b..a58ba9116ee 100644 --- a/addons/mail/models/models.py +++ b/addons/mail/models/models.py @@ -221,7 +221,7 @@ class BaseModel(models.AbstractModel): return E.activity(templates, string=self._description) # ------------------------------------------------------------ - # GATEWAY: NOTIFICATION + # DISCUSS # ------------------------------------------------------------ def _mail_get_message_subtypes(self): @@ -229,6 +229,10 @@ class BaseModel(models.AbstractModel): '&', ('hidden', '=', False), '|', ('res_model', '=', self._name), ('res_model', '=', False)]) + # ------------------------------------------------------------ + # GATEWAY: NOTIFICATION + # ------------------------------------------------------------ + def _notify_by_email_get_headers(self): """ Generate the email headers based on record """ if not self: diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index 4c3f696ef50..1ed0a086087 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -1366,6 +1366,14 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): Test with and without notification layout specified. Test with and without languages. + + Setup with batch and langs + * record1: customer lang=es_ES + follower partner_employee_2 lang=en_US + 3 new partners (en_US) created by template + * record2: customer lang=en_US + follower partner_employee_2 lang=en_US + 3 new partners (en_US) created by template """ attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) email_to_1 = 'test.to.1@test.example.com' @@ -1465,10 +1473,32 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): self.assertFalse(self._mails) # monorecord: force_send notifications elif not batch: + # as there are recipients with different langs: we have + # 3 outgoing mails: partner_employee2 (user) then customers + # in two langs + if use_lang: + self.assertEqual( + len(self._new_mails), 3, + 'Should have created 1 mail for user, then 2 for customers that belong to 2 langs') + # without lang, recipients are grouped by main usage aka user and customer + else: + self.assertEqual( + len(self._new_mails), 2, + 'Should have created 1 mail for user, then 1 for customers') self.assertEqual(self._new_mails.mapped('state'), ['sent'] * len(self._new_mails)) self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient per record') # multirecord: use email queue else: + # see not-batch comment, then add 2 mails for the second + # record as all customers have same language + if use_lang: + self.assertEqual( + len(self._new_mails), 5, + 'Should have created 3 mails for first record, then 2 for second') + else: + self.assertEqual( + len(self._new_mails), 4, + 'Should have created 2 mails / record (one for user, one for customers)') self.assertEqual(self._new_mails.mapped('state'), ['outgoing'] * len(self._new_mails)) self.assertEqual(len(self._mails), 0, 'Should have put emails in queue and not sent any emails') # simulate cron sending emails @@ -1505,18 +1535,36 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): freeze_time(self.reference_now + timedelta(days=3)): self.env['mail.message.schedule'].sudo()._send_notifications_cron() - # global outgoing: one mail.mail (all customer recipients, then all employee recipients) - # and 5 emails, and 1 inbox notification (admin) - self.assertEqual(len(self._new_mails), 2 * len(test_records), - 'Should have created 1 mail.mail per group per record') + # monorecord: force_send notifications if not batch: + # as there are recipients with different langs: we have + # 3 outgoing mails: partner_employee2 (user) then customers + # in two langs + if use_lang: + self.assertEqual( + len(self._new_mails), 3, + 'Should have created 1 mail for user, then 2 for customers that belong to 2 langs') + # without lang, recipients are grouped by main usage aka user and customer + else: + self.assertEqual( + len(self._new_mails), 2, + 'Should have created 1 mail for user, then 1 for customers') self.assertEqual(self._new_mails.mapped('state'), ['sent'] * len(self._new_mails)) - self.assertEqual(len(self._mails), 5, - 'Should have sent 5 emails, one per recipient per record') + self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient per record') + # multirecord: use email queue else: + # see not-batch comment, then add 2 mails for the second + # record as all customers have same language + if use_lang: + self.assertEqual( + len(self._new_mails), 5, + 'Should have created 3 mails for first record, then 2 for second') + else: + self.assertEqual( + len(self._new_mails), 4, + 'Should have created 2 mails / record (one for user, one for customers)') self.assertEqual(self._new_mails.mapped('state'), ['outgoing'] * len(self._new_mails)) - self.assertEqual(len(self._mails), 0, - 'Should have kept force_send False parameter, and not sent any email') + self.assertEqual(len(self._mails), 0, 'Should have put emails in queue and not sent any emails') # simulate cron sending emails self.env['mail.mail'].sudo().process_email_queue() @@ -1525,12 +1573,13 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): message = test_record.message_ids[0] # check created mail.mail and outgoing emails. In comment - # 2 mails are generated (due to group-based layouting): + # 2 or 3 mails are generated (due to group-based layouting): # - one for recipient that is a user - # - one for recipients that are customers - # Then each recipient receives its own outging email. See + # - one / two for recipients that are customers, one / lang + # Then each recipient receives its own outgoing email. See # 'assertMailMail' for more details. + # user email (one user, one email) if exp_lang == 'es_ES': exp_body = f'SpanishBody for {test_record.name}' exp_subject = f'SpanishSubject for {test_record.name}' @@ -1555,29 +1604,46 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): 'mail_server_id': self.mail_server_domain, }, ) - self.assertMailMail(test_record.customer_id + new_partners, 'sent', - mail_message=message, - author=author, # author is different in batch and monorecord mode (raw or rendered email_from) - email_values={ - 'body_content': exp_body, - 'email_from': test_record.user_id.email_formatted, # set by template - 'subject': exp_subject, - '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'TestReport for {test_record.name}.html', 'type': 'text/plain'}, - {'name': f'TestReport2 for {test_record.name}.html', 'type': 'text/plain'}, - ] - }, - fields_values={ - 'mail_server_id': self.mail_server_domain, - }, - ) - # Low-level checks on outgoing email for the recipient to + # customers emails (several customers, one or two emails depending + # on multi-lang testing environment) + if use_lang and test_record == test_records[0]: + # in this case, we are in a multi-lang customers testing + emails_recipients = [ + test_record.customer_id, # es_ES + new_partners # en_US (default lang of new customers) + ] + else: + # all recipients have same language, one email + emails_recipients = [test_record.customer_id + new_partners] + + for recipients in emails_recipients: + self.assertMailMail(recipients, 'sent', + mail_message=message, + author=author, # author is different in batch and monorecord mode (raw or rendered email_from) + email_values={ + 'body_content': exp_body, + 'email_from': test_record.user_id.email_formatted, # set by template + 'subject': exp_subject, + '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'TestReport for {test_record.name}.html', 'type': 'text/plain'}, + {'name': f'TestReport2 for {test_record.name}.html', 'type': 'text/plain'}, + ] + }, + fields_values={ + 'mail_server_id': self.mail_server_domain, + }, + ) + + # Specifically for the language-specific recipient, perform + # low-level checks on outgoing email for the recipient to # check layouting and language. Note that standard layout # is not tested against translations, only the custom one # to ease translations checks. + # We could do the check for other layouts but it would be + # mainly noisy / duplicated check email = self._find_sent_email(test_record.user_id.email_formatted, [test_record.customer_id.email_formatted]) self.assertTrue(bool(email), 'Email not found, check recipients') diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index 0d3aa00f546..99aa1a72673 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -1518,8 +1518,9 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): @mute_logger('odoo.addons.mail.models.mail_mail') def test_composer_lang_template_comment(self): """ When posting in comment mode, content is rendered using the lang - field of template. Notification layout lang is partly coming from - template, partly from environment (to be improved). """ + field of template. Notification layout lang is the one from the + customer to personalize the context. When not found it fallbacks + on rendered template lang or environment lang. """ test_record = self.test_records[0].with_user(self.env.user) test_template = self.test_template.with_user(self.env.user) @@ -1566,9 +1567,8 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): else: self.assertEqual(f'SpanishSubject for {test_record.name}', customer_email['subject'], 'Subject based on template should be translated') - # check notification layout content: currently partly translated - # based only on template definition - if exp_content_lang == 'en_US': + # check notification layout content: depends on customer lang + if exp_notif_lang == 'en_US': self.assertNotIn('Spanish Layout para', body, 'Layout translation failed') self.assertIn('English Layout for Lang Chatter Model', body, 'Layout / model translation failed') @@ -1583,14 +1583,19 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): self.assertIn('NotificationButtonTitle', body, 'Groups-based action names translation failed') else: + self.assertNotIn('English Layout for', body, 'Layout translation failed') + self.assertIn('Spanish Layout para Spanish Model Description', body, + 'Layout / model translation failed') + self.assertNotIn('Lang Chatter Model', body, 'Model translation failed') + # check notification layout strings self.assertIn('SpanishView Spanish Model Description', body, - '"View document" should be translated') + '"View document" translation failed') self.assertNotIn(f'View {test_record._description}', body, - '"View document" should be translated') + '"View document" translation failed') self.assertIn('SpanishButtonTitle', body, - 'Groups-based action names should be translated') + 'Groups-based action names translation failed') self.assertNotIn('NotificationButtonTitle', body, - 'Groups-based action names should be translated') + 'Groups-based action names translation failed') @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -1658,7 +1663,8 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): @mute_logger('odoo.addons.mail.models.mail_mail') def test_layout_email_lang_template(self): """ Test language support when posting in batch using a template. - Content and layout are is translated based on template definition. """ + Content is translated based on template definition, layout based on + customer lang. """ test_records = self.test_records.with_user(self.env.user) test_template = self.test_template.with_user(self.env.user) @@ -1673,7 +1679,11 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): record0_customer = self.env['res.partner'].search([('email_normalized', '=', 'test.record.1@test.customer.com')], limit=1) self.assertTrue(record0_customer, 'Template usage should have created a contact based on record email') - for record, customer in zip(test_records, record0_customer + self.partner_2): + for record, customer, exp_notif_lang in zip( + test_records, + record0_customer + self.partner_2, + ('en_US', 'es_ES') # new customer is en_US, partner_2 is es_ES + ): customer_email = self._find_sent_mail_wemail(customer.email_formatted) self.assertTrue(customer_email) @@ -1688,19 +1698,34 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): self.assertEqual(f'SpanishSubject for {record.name}', customer_email['subject'], 'Subject based on template should be translated') # check notification layout translation - self.assertIn('Spanish Layout para', body, - 'Layout content should be translated') - self.assertNotIn('English Layout for', body) - self.assertIn('Spanish Layout para Spanish Model Description', body, - 'Model name should be translated') - self.assertIn('SpanishView Spanish Model Description', body, - '"View document" should be translated') - self.assertNotIn(f'View {test_records[1]._description}', body, - '"View document" should be translated') - self.assertIn('SpanishButtonTitle', body, - 'Groups-based action names should be translated') - self.assertNotIn('NotificationButtonTitle', body, - 'Fixme: groups-based action names should be translated') + if exp_notif_lang == 'en_US': + self.assertNotIn('Spanish Layout para', body, + 'Layout content should be translated') + self.assertIn('English Layout for', body) + self.assertNotIn('Spanish Layout para Spanish Model Description', body, + 'Model name should be translated') + self.assertNotIn('SpanishView Spanish Model Description', body, + '"View document" should be translated') + self.assertIn(f'View {test_records[1]._description}', body, + '"View document" should be translated') + self.assertNotIn('SpanishButtonTitle', body, + 'Groups-based action names should be translated') + self.assertIn('NotificationButtonTitle', body, + 'Groups-based action names should be translated') + else: + self.assertIn('Spanish Layout para', body, + 'Layout content should be translated') + self.assertNotIn('English Layout for', body) + self.assertIn('Spanish Layout para Spanish Model Description', body, + 'Model name should be translated') + self.assertIn('SpanishView Spanish Model Description', body, + '"View document" should be translated') + self.assertNotIn(f'View {test_records[1]._description}', body, + '"View document" should be translated') + self.assertIn('SpanishButtonTitle', body, + 'Groups-based action names should be translated') + self.assertNotIn('NotificationButtonTitle', body, + 'Groups-based action names should be translated') @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -1743,29 +1768,34 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): ) # check created mail.mail and outgoing emails. One email - # is generated for 'partner_1' and 'partner_2' (same email - # as same configuration (aka all customers)). - _mail = self.assertMailMail( - self.partner_1 + self.partner_2, 'sent', - mail_message=message, - author=self.partner_employee, - email_values={ - 'body_content': '

Hi there

', - 'email_from': self.partner_employee.email_formatted, - 'subject': 'TeDeum', - }, - ) + # is generated for each partner 'partner_1' and 'partner_2' + # different language thus different layout + for partner in self.partner_1 + self.partner_2: + _mail = self.assertMailMail( + partner, 'sent', + mail_message=message, + author=self.partner_employee, + email_values={ + 'body_content': '

Hi there

', + 'email_from': self.partner_employee.email_formatted, + 'subject': 'TeDeum', + }, + ) # Low-level checks on outgoing email for the recipient to # check layouting and language. Note that standard layout # is not tested against translations, only the custom one # to ease translations checks. - for partner in self.partner_1 + self.partner_2: + for partner, exp_lang in zip( + self.partner_1 + self.partner_2, + ('en_US', 'es_ES') + ): email = self._find_sent_email( self.partner_employee.email_formatted, [partner.email_formatted] ) self.assertTrue(bool(email), 'Email not found, check recipients') + self.assertEqual(partner.lang, exp_lang, 'Test misconfiguration') exp_layout_content_en = 'English Layout for Lang Chatter Model' exp_layout_content_es = 'Spanish Layout para Spanish Model Description' @@ -1774,7 +1804,7 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): exp_action_en = 'NotificationButtonTitle' exp_action_es = 'SpanishButtonTitle' if email_layout_xmlid: - if employee_lang == 'es_ES': + if exp_lang == 'es_ES': self.assertIn(exp_layout_content_es, email['body']) self.assertIn(exp_button_es, email['body']) self.assertIn(exp_action_es, email['body']) @@ -1784,7 +1814,7 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): self.assertIn(exp_action_en, email['body']) else: # check default layouting applies - if employee_lang == 'es_ES': + if exp_lang == 'es_ES': self.assertIn('html lang="es_ES"', email['body']) else: self.assertIn('html lang="en_US"', email['body'])