From 3dac7dd5c09a822c6cc50f94fa7163c95f744a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 3 Nov 2022 10:44:56 +0000 Subject: [PATCH 1/5] [IMP] mail: support rendering on void or falsy record sets In this commit we support calling rendering methods on void or falsy record sets. Currently some rendering methods prevent from having 'None', some do nothing specific. As Qweb or inline template support rendering on void records we can safely support rendering on [], (), [False], [None]. Returned result will either be void, either contain the rendered on void record for the False or None key. In this commit we also add a protection against trying to render a non existing field. Instead of raising a KeyError, a ValueError is raised with a clearer error message. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#106072 --- addons/mail/models/mail_render_mixin.py | 20 +++++------ addons/mail/tests/test_mail_render.py | 44 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index 8ad6fe4a913..75929065faa 100644 --- a/addons/mail/models/mail_render_mixin.py +++ b/addons/mail/models/mail_render_mixin.py @@ -289,7 +289,7 @@ class MailRenderMixin(models.AbstractModel): :return dict: {res_id: string of rendered template based on record} """ results = dict.fromkeys(res_ids, u"") - if not template_src: + if not template_src or not res_ids: return results # prepare template variables @@ -341,11 +341,9 @@ class MailRenderMixin(models.AbstractModel): :return dict: {res_id: string of rendered template based on record} """ - # prevent wrong values (rendering on a void record set, ...) - if any(r is None for r in res_ids): - raise ValueError(_('Template rendering should be called on a valid record IDs.')) - results = {} + if not res_ids: + return results # prepare template variables variables = self._render_eval_context() @@ -384,12 +382,8 @@ class MailRenderMixin(models.AbstractModel): :return dict: {res_id: string of rendered template based on record} """ - # prevent wrong values (rendering on a void record set, ...) - if any(r is None for r in res_ids): - raise ValueError(_('Template rendering should be called on a valid record IDs.')) - results = dict.fromkeys(res_ids, u"") - if not template_txt: + if not template_txt or not res_ids: return results template_instructions = parse_inline_template(str(template_txt)) @@ -554,6 +548,12 @@ class MailRenderMixin(models.AbstractModel): :return dict: {res_id: string of rendered template based on record} """ + if field not in self: + raise ValueError( + _('Rendering of %(field_name)s is not possible as not defined on template.', + field_name=field + ) + ) if options is None: options = {} diff --git a/addons/mail/tests/test_mail_render.py b/addons/mail/tests/test_mail_render.py index b196063043c..b97f9a0305f 100644 --- a/addons/mail/tests/test_mail_render.py +++ b/addons/mail/tests/test_mail_render.py @@ -97,6 +97,13 @@ class TestMailRenderCommon(common.MailCommon): Autre Narrateur

""" ] + cls.base_rendered_void = [ + '

Hello

', + '

Hello

', + """

+ English Speaker +

""" + ] # link to mail template cls.test_template = cls.env['mail.template'].create({ @@ -198,6 +205,43 @@ class TestMailRender(TestMailRenderCommon): )[partner.id] self.assertEqual(rendered, expected) + @users('employee') + def test_render_field_no_records(self): + """ Test rendering on void IDs, or a list with dummy / falsy ID """ + template = self.test_template.with_env(self.env) + partner = self.render_object.with_env(self.env) + for res_ids in ([], (), [False], [''], [None], [False, partner.id]): # various corner cases + for fname, expected_obj, expected_void in zip(['subject', 'body_html'], self.base_rendered, self.base_rendered_void): + with self.subTest(): + rendered_all = template._render_field( + fname, + res_ids, + compute_lang=True + ) + if res_ids: + self.assertTrue(res_ids[0] in rendered_all, + f'Rendering: key {repr(res_ids[0])} is considered as valid and should have an entry') + self.assertEqual(rendered_all[res_ids[0]], expected_void) + if len(res_ids) == 2: # second is partner + self.assertTrue(res_ids[1] in rendered_all) + self.assertEqual(rendered_all[res_ids[1]], expected_obj) + if not res_ids: + self.assertFalse(rendered_all, + 'Rendering: void input -> void output') + + @users('employee') + def test_render_field_not_existing(self): + """ Test trying to render a not-existing field: raise a proper ValueError + instead of crashing / raising a KeyError """ + template = self.env['mail.template'].browse(self.test_template.ids) + partner = self.env['res.partner'].browse(self.render_object_fr.ids) + with self.assertRaises(ValueError): + _rendered = template._render_field( + 'not_existing', + partner.ids, + compute_lang=True + )[partner.id] + @users('employee') def test_render_template_inline_template(self): partner = self.env['res.partner'].browse(self.render_object.ids) From 902cda0a1beab3cfd28a8d60b1d1ea462be872cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 27 Oct 2022 14:38:01 +0000 Subject: [PATCH 2/5] [IMP] test_mail: add some tests on content translation Purpose of this commit is to add tests on translations when using templates and composer, on both email content (body, subject) and email layouting content (view content, groups-based buttons, ...). This better highlights some missing part of translation support, notably * when being in mass mode: as content is taken from the composer and rendered dynamically, it does not take the original translation from the template even if it matches template content exactly; * access button title is not translated as it is added during a separate part of the process; We also improve tests for ``mail.composer.mixin``. The wizard used in test (mail.test.composer.mixin) is improve to behave more like a real invite wizard: it runs on records (mail.test.composer.source) which are records on which dynamic content is rendered. This highlights one issue with translations: translations are fetched on current model (aka: composer body and subject fields). However when using templates, translations are stored on template model (aka: mail.template). This will be improved soon by trying to fetch template translations when composer content matches content from template. Task-3046371 (Mail: Improve language propagation in mail and layouting) Part-of: odoo/odoo#106072 --- addons/mail/tests/common.py | 23 ++-- .../models/test_mail_corner_case_models.py | 2 +- addons/test_mail/models/test_mail_models.py | 25 +++- addons/test_mail/security/ir.model.access.csv | 2 + .../tests/test_mail_composer_mixin.py | 108 ++++++++++++++---- addons/test_mail/tests/test_mail_template.py | 4 +- addons/test_mail/tests/test_message_post.py | 106 +++++++++++++---- 7 files changed, 209 insertions(+), 61 deletions(-) diff --git a/addons/mail/tests/common.py b/addons/mail/tests/common.py index 3912398d5c3..675779e19e9 100644 --- a/addons/mail/tests/common.py +++ b/addons/mail/tests/common.py @@ -19,7 +19,7 @@ from odoo.addons.mail.models.mail_mail import MailMail from odoo.addons.mail.models.mail_message import Message from odoo.addons.mail.models.mail_notification import MailNotification from odoo.tests import common, new_test_user -from odoo.tools import formataddr, pycompat +from odoo.tools import formataddr, mute_logger, pycompat from odoo.tools.translate import code_translations mail_new_test_user = partial(new_test_user, context={'mail_create_nolog': True, @@ -1102,9 +1102,9 @@ class MailCommon(common.TransactionCase, MailCase): * 'English Layout for' -> Spanish Layout para * model * description: English: Lang Chatter Model (depends on test_record._name) - translated: Spanish description + translated: Spanish Model Description * module - * _('TestStuff') -> TestSpanishStuff (used as link button name in layout) + * _('NotificationButtonTitle') -> SpanishNotificationButtonTitle (used as link button name in layout) * _('View %s') -> SpanishView %s * template * body: English:

EnglishBody for

(depends on test_template.body) @@ -1114,17 +1114,22 @@ class MailCommon(common.TransactionCase, MailCase): """ # activate translations cls.env['res.lang']._activate_lang(lang_code) - cls.env.ref('base.module_base')._update_translations([lang_code]) + with mute_logger("odoo.addons.base.models.ir_module", "odoo.tools.translate"): + cls.env.ref('base.module_base')._update_translations([lang_code]) + cls.env.ref('base.module_mail')._update_translations([lang_code]) + cls.env.ref('base.module_test_mail')._update_translations([lang_code]) + code_translations.get_python_translations('mail', lang_code) + code_translations.get_python_translations('test_mail', lang_code) # Make sure Spanish translations have not been altered if test_record: - cls.env['ir.model']._get(test_record._name).with_context(lang=lang_code).name = 'Spanish description' + cls.env['ir.model']._get(test_record._name).with_context(lang=lang_code).name = 'Spanish Model Description' # Translate some code strings used in mailing - code_translations.python_translations[('test_mail', 'es_ES')] = {'TestStuff': 'TestSpanishStuff'} - cls.addClassCleanup(code_translations.python_translations.pop, ('test_mail', 'es_ES')) - code_translations.python_translations[('mail', 'es_ES')] = {'View %s': 'SpanishView %s'} - cls.addClassCleanup(code_translations.python_translations.pop, ('mail', 'es_ES')) + code_translations.python_translations[('test_mail', 'es_ES')]['NotificationButtonTitle'] = 'SpanishButtonTitle' + cls.addClassCleanup(code_translations.python_translations[('test_mail', 'es_ES')].pop, 'NotificationButtonTitle') + code_translations.python_translations[('mail', 'es_ES')]['View %s'] = 'SpanishView %s' + cls.addClassCleanup(code_translations.python_translations[('mail', 'es_ES')].pop, 'View %s') # Prepare some translated value for template if given if test_template: diff --git a/addons/test_mail/models/test_mail_corner_case_models.py b/addons/test_mail/models/test_mail_corner_case_models.py index e59aa431e40..31fd75ca27c 100644 --- a/addons/test_mail/models/test_mail_corner_case_models.py +++ b/addons/test_mail/models/test_mail_corner_case_models.py @@ -76,7 +76,7 @@ class MailTestLang(models.Model): group_options['has_button_access'] = True group_options['actions'] = [ {'url': self._notify_get_action_link('controller', controller='/test_mail/do_stuff', **local_msg_vals), - 'title': _('TestStuff')} + 'title': _('NotificationButtonTitle')} ] return groups diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index 8b3eec16f95..284e86956f6 100644 --- a/addons/test_mail/models/test_mail_models.py +++ b/addons/test_mail/models/test_mail_models.py @@ -225,7 +225,9 @@ class MailTestContainerMC(models.Model): class MailTestComposerMixin(models.Model): """ A simple invite-like wizard using the composer mixin, rendering on - itself. """ + composer source test model. Purpose is to have a minimal composer which + runs on other records and check notably dynamic template support and + translations. """ _description = 'Invite-like Wizard' _name = 'mail.test.composer.mixin' _inherit = ['mail.composer.mixin'] @@ -233,6 +235,25 @@ class MailTestComposerMixin(models.Model): name = fields.Char('Name') author_id = fields.Many2one('res.partner') description = fields.Html('Description', render_engine="qweb", render_options={"post_process": True}, sanitize=False) + source_ids = fields.Many2many('mail.test.composer.source', string='Invite source') def _compute_render_model(self): - self.render_model = self._name + self.render_model = 'mail.test.composer.source' + + +class MailTestComposerSource(models.Model): + """ A simple model on which invites are sent. """ + _description = 'Invite-like Wizard' + _name = 'mail.test.composer.source' + _inherit = ['mail.thread.blacklist'] + _primary_email = 'email_from' + + name = fields.Char('Name') + customer_id = fields.Many2one('res.partner', 'Main customer') + email_from = fields.Char( + 'Email', + compute='_compute_email_from', readonly=False, store=True) + + def _compute_email_from(self): + for source in self.filtered(lambda r: r.customer_id and not r.email_from): + source.email_from = source.customer_id.email_formatted diff --git a/addons/test_mail/security/ir.model.access.csv b/addons/test_mail/security/ir.model.access.csv index 0c6126ffb34..2fac058e400 100644 --- a/addons/test_mail/security/ir.model.access.csv +++ b/addons/test_mail/security/ir.model.access.csv @@ -19,6 +19,8 @@ access_mail_test_ticket_mc_portal,mail.test.ticket.mc.portal,model_mail_test_tic access_mail_test_ticket_mc_user,mail.test.ticket.mc.user,model_mail_test_ticket_mc,base.group_user,1,1,1,1 access_mail_test_composer_mixin_all,mail.test.composer.mixin.all,model_mail_test_composer_mixin,,0,0,0,0 access_mail_test_composer_mixin_user,mail.test.composer.mixin.user,model_mail_test_composer_mixin,base.group_user,1,1,1,1 +access_mail_test_composer_source_all,mail.test.composer.source.all,model_mail_test_composer_source,,1,0,0,0 +access_mail_test_composer_source_user,mail.test.composer.source.user,model_mail_test_composer_source,base.group_user,1,1,1,1 access_mail_test_container_portal,mail.test.container_portal,model_mail_test_container,base.group_portal,1,0,0,0 access_mail_test_container_user,mail.test.container.user,model_mail_test_container,base.group_user,1,1,1,1 access_mail_test_container_mc_portal,mail.test.container.mc.portal,model_mail_test_container_mc,base.group_portal,1,0,0,0 diff --git a/addons/test_mail/tests/test_mail_composer_mixin.py b/addons/test_mail/tests/test_mail_composer_mixin.py index 5b85c34ed5c..9591646b4a0 100644 --- a/addons/test_mail/tests/test_mail_composer_mixin.py +++ b/addons/test_mail/tests/test_mail_composer_mixin.py @@ -13,39 +13,101 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients): def setUpClass(cls): super(TestMailComposerMixin, cls).setUpClass() - cls.mail_template = cls.env['mail.template'].create({ - 'subject': 'Subject for {{ object.name }}', - 'body_html': '

Body for

', + # ensure employee can create partners, necessary for templates + cls.user_employee.write({ + 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], }) + cls.mail_template = cls.env['mail.template'].create({ + 'body_html': '

EnglishBody for

', + 'model_id': cls.env['ir.model']._get('mail.test.composer.source').id, + 'name': 'Test Template for mail.test.composer.source', + 'lang': '{{ object.customer_id.lang }}', + 'subject': 'EnglishSubject for {{ object.name }}', + }) + cls.test_record = cls.env['mail.test.composer.source'].create({ + 'name': cls.partner_1.name, + 'customer_id': cls.partner_1.id, + }) + + cls._activate_multi_lang( + layout_arch_db=' English Layout for ', + lang_code='es_ES', + test_record=cls.test_record, + test_template=cls.mail_template, + ) + @users("employee") def test_content_sync(self): - record = self.env['mail.test.composer.mixin'].create({ + """ Test updating template updates the dynamic fields accordingly. """ + source = self.test_record.with_env(self.env) + composer = self.env['mail.test.composer.mixin'].create({ 'name': 'Invite', 'template_id': self.mail_template.id, + 'source_ids': [(4, source.id)], }) - self.assertEqual(record.subject, self.mail_template.subject) - self.assertEqual(record.body, self.mail_template.body_html) + self.assertEqual(composer.body, self.mail_template.body_html) + self.assertEqual(composer.subject, self.mail_template.subject) + self.assertFalse(composer.lang, 'Fixme: lang is not propagated currently') - subject = record._render_field('subject', record.ids)[record.id] - self.assertEqual(subject, 'Subject for %s' % record.name) - body = record._render_field('body', record.ids)[record.id] - self.assertEqual(body, '

Body for %s

' % record.name) + subject = composer._render_field('subject', source.ids)[source.id] + self.assertEqual(subject, f'EnglishSubject for {source.name}') + body = composer._render_field('body', source.ids)[source.id] + self.assertEqual(body, f'

EnglishBody for {source.name}

') @users("employee") - def test_rendering(self): - record = self.env['mail.test.composer.mixin'].create({ - 'name': 'Invite', - 'subject': 'Subject for {{ object.name }}', - 'body': '

Content from

', + def test_rendering_custom(self): + """ Test rendering with custom strings (not coming from template) """ + source = self.test_record.with_env(self.env) + composer = self.env['mail.test.composer.mixin'].create({ 'description': '

Description for

', + 'body': '

SpecificBody from

', + 'name': 'Invite', + 'subject': 'SpecificSubject for {{ object.name }}', }) - self.assertEqual(record.subject, 'Subject for {{ object.name }}') - self.assertEqual(record.body, '

Content from

') + self.assertEqual(composer.body, '

SpecificBody from

') + self.assertEqual(composer.subject, 'SpecificSubject for {{ object.name }}') - subject = record._render_field('subject', record.ids)[record.id] - self.assertEqual(subject, 'Subject for %s' % record.name) - body = record._render_field('body', record.ids)[record.id] - self.assertEqual(body, '

Content from %s

' % self.env.user.name) - description = record._render_field('description', record.ids)[record.id] - self.assertEqual(description, '

Description for Invite

') + subject = composer._render_field('subject', source.ids)[source.id] + self.assertEqual(subject, f'SpecificSubject for {source.name}') + body = composer._render_field('body', source.ids)[source.id] + self.assertEqual(body, f'

SpecificBody from {self.env.user.name}

') + description = composer._render_field('description', source.ids)[source.id] + self.assertEqual(description, f'

Description for {source.name}

') + + @users("employee") + def test_rendering_lang(self): + """ Test rendering with language involved """ + template = self.mail_template.with_env(self.env) + customer = self.partner_1.with_env(self.env) + customer.lang = 'es_ES' + source = self.test_record.with_env(self.env) + composer = self.env['mail.test.composer.mixin'].create({ + 'description': '

Description for

', + 'lang': '{{ object.customer_id.lang }}', + 'name': 'Invite', + 'template_id': self.mail_template.id, + 'source_ids': [(4, source.id)], + }) + self.assertEqual(composer.body, template.body_html) + self.assertEqual(composer.subject, template.subject) + self.assertEqual(composer.lang, '{{ object.customer_id.lang }}') + + # do not specifically ask for language computation + subject = composer._render_field('subject', source.ids, compute_lang=False)[source.id] + self.assertEqual(subject, f'EnglishSubject for {source.name}') + body = composer._render_field('body', source.ids, compute_lang=False)[source.id] + self.assertEqual(body, f'

EnglishBody for {source.name}

') + description = composer._render_field('description', source.ids)[source.id] + self.assertEqual(description, f'

Description for {source.name}

') + + # ask for dynamic language computation + subject = composer._render_field('subject', source.ids, compute_lang=True)[source.id] + self.assertEqual(subject, f'EnglishSubject for {source.name}', + 'Fixme: translations are not done, as taking composer translations and not template one') + body = composer._render_field('body', source.ids, compute_lang=True)[source.id] + self.assertEqual(body, f'

EnglishBody for {source.name}

', + 'Fixme: translations are not done, as taking composer translations and not template one' + ) + description = composer._render_field('description', source.ids)[source.id] + self.assertEqual(description, f'

Description for {source.name}

') diff --git a/addons/test_mail/tests/test_mail_template.py b/addons/test_mail/tests/test_mail_template.py index fb1bec1977e..678df090b6e 100644 --- a/addons/test_mail/tests/test_mail_template.py +++ b/addons/test_mail/tests/test_mail_template.py @@ -128,7 +128,7 @@ class TestMailTemplateLanguages(TestMailTemplateCommon): mail_id = test_template.send_mail(test_record.id, email_layout_xmlid='mail.test_layout') mail = self.env['mail.mail'].sudo().browse(mail_id) self.assertEqual(mail.body_html, - '

SpanishBody for %s

Spanish Layout para Spanish description' % self.test_record.name) + '

SpanishBody for %s

Spanish Layout para Spanish Model Description' % self.test_record.name) self.assertEqual(mail.subject, 'SpanishSubject for %s' % self.test_record.name) @mute_logger('odoo.addons.mail.models.mail_mail') @@ -147,5 +147,5 @@ class TestMailTemplateLanguages(TestMailTemplateCommon): mail_id = test_template.send_mail(test_record.id, email_layout_xmlid='mail.test_layout') mail = self.env['mail.mail'].sudo().browse(mail_id) self.assertEqual(mail.body_html, - '

SpanishBody for %s

Spanish Layout para Spanish description' % self.test_record.name) + '

SpanishBody for %s

Spanish Layout para Spanish Model Description' % self.test_record.name) self.assertEqual(mail.subject, 'SpanishSubject for %s' % self.test_record.name) diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index 7341c9fd44a..b23052cc450 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -1210,7 +1210,49 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') - def test_composer_lang_template(self): + def test_composer_lang_template_comment(self): + test_record = self.test_records[0].with_user(self.env.user) + test_template = self.test_template.with_user(self.env.user) + + with self.mock_mail_gateway(): + test_record.message_post_with_template( + test_template.id, + composition_mode='comment', + email_layout_xmlid='mail.test_layout', + message_type='comment', + subtype_id=self.env.ref('mail.mt_comment').id, + ) + + 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') + + customer_email = self._find_sent_mail_wemail(record0_customer.email_formatted) + self.assertTrue(customer_email) + body = customer_email['body'] + # check content + self.assertIn(f'SpanishBody for {test_record.name}', body, + 'Body based on template should be translated') + # check subject + self.assertEqual(f'SpanishSubject for {test_record.name}', customer_email['subject'], + 'Subject based on template should be translated') + # check notification layout content + 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') + # check notification layout strings + self.assertIn('View Lang Chatter Model', body, + 'Fixme: "View document" should be translated') + # self.assertIn('SpanishView Spanish Model Description', body, + # '"View document" should be translated') + # self.assertNotIn(f'View {test_record._description}', body, + # '"View document" should be translated') + # self.assertIn('SpanishButtonTitle', body, + # 'Groups-based action names should be translated') + self.assertIn('NotificationButtonTitle', body, 'Fixme: Groups-based action names should be translated') + + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_composer_lang_template_mass(self): test_records = self.test_records.with_user(self.env.user) test_template = self.test_template.with_user(self.env.user) @@ -1231,11 +1273,15 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): self.assertTrue(customer_email) body = customer_email['body'] # check content - # self.assertIn('SpanishBody for %s' % record.name, body, 'Body based on template should be translated') - self.assertIn('EnglishBody for %s' % record.name, body, 'Fixme: this should be translated') + # self.assertIn(f'SpanishBody for {record.name}', body, + # 'Body based on template should be translated') + self.assertIn(f'EnglishBody for {record.name}', body, + 'Fixme: Body based on template should be translated') # check subject - # self.assertEqual('SpanishSubject for %s' % record.name, customer_email['subject'], 'Subject based on template should be translated') - self.assertEqual('EnglishSubject for %s' % record.name, customer_email['subject'], 'Fixme: this should be translated') + # self.assertEqual(f'SpanishSubject for {record.name}', customer_email['subject'], + # 'Subject based on template should be translated') + self.assertEqual(f'EnglishSubject for {record.name}', customer_email['subject'], + 'Fixme: Subject based on template should be translated') @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -1255,16 +1301,20 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): customer_email = self._find_sent_mail_wemail(self.partner_2.email_formatted) self.assertTrue(customer_email) body = customer_email['body'] - # 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 description', body, 'Model name should be translated') - self.assertIn('SpanishView Spanish description', body, '"View document" should be translated') - self.assertNotIn('View %s' % test_records[1]._description, body) - self.assertIn('TestSpanishStuff', body, 'Groups-based action names should be translated') - self.assertNotIn('TestStuff', body) # check content - self.assertIn('Hello', body, 'Body of posted message should be present') + self.assertIn('

Hello

', body, 'Body of posted message should be present') + # check notification layout content + 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') + # check notification layout strings + self.assertIn('SpanishView Spanish Model Description', body, + '"View document" should be translated') + self.assertNotIn(f'View {test_records[1]._description}', body) + self.assertIn('SpanishButtonTitle', body, 'Groups-based action names should be translated') + self.assertNotIn('NotificationButtonTitle', body) @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') @@ -1288,15 +1338,23 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): customer_email = self._find_sent_mail_wemail(customer.email_formatted) self.assertTrue(customer_email) body = customer_email['body'] - # 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 description', body, 'Model name should be translated') - # self.assertIn('SpanishView Spanish description', body, '"View document" should be translated') - self.assertIn('View %s' % test_records[1]._description, body, 'Fixme: this should be translated') - # self.assertIn('TestSpanishStuff', body, 'Groups-based action names should be translated') - self.assertIn('TestStuff', body, 'Fixme: groups-based action names should be translated') # check content - self.assertIn('SpanishBody for %s' % record.name, body, 'Body based on template should be translated') + self.assertIn(f'SpanishBody for {record.name}', body, + 'Body based on template should be translated') # check subject - self.assertEqual('SpanishSubject for %s' % record.name, customer_email['subject'], 'Subject based on template should be translated') + 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.assertIn(f'View {test_records[1]._description}', body, + 'Fixme: "View document" should be translated') + # self.assertIn('NotificationButtonTitle', body, + # 'Groups-based action names should be translated') + self.assertIn('NotificationButtonTitle', body, + 'Fixme: groups-based action names should be translated') From 3d5b0125012fecd3ee2611d140d454dd7b9d9f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 9 Dec 2021 13:08:28 +0000 Subject: [PATCH 3/5] [REF] mail, various: cleanup usage of options in render mixin Due to recent improvements (Jinja -> Qweb, safe rendering) rendering API supports several ways of giving options to the rendering process. Those have mainly two usage * ``preserve_comments`` : keep comments in rendered HTML, used notably in mass mailing or digest to keep browser-specific comments; * ``post_process`` : perform a post processing on rendered HTML, used notably to process local links and add tracking to shortened links; All those are now given directly inside an optional ``options`` parameter given to ``_render_field`` and its sub-method ``_render_template``. They can also be defined as field level, using ``render_options`` field parameter. Some fields are updated * composer mixin body (which impacts all inheriting models, notably mail composer, survey and elearning invite wizards as well as appraisal and appraisal feedback): post processing is now always done by default. It was already done manually in calls that can be simplified; * mail template body_html: post processing is now always done by default. It was already done manually in calls that can be simplified; * mailing body_html and preview are now post processed by default. It was already the case in testing wizard. Preview is updated to avoid post processing of links, as it was before. Remaining use case is the sending which uses the mail composer, therefore was already post processed; Finally some 'compute_lang' are explicitly added in _render_field calls in order to be explicit on what we want, instead of being unsure. Task-2710804 (Mail: Clean MailThread API) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#106072 --- addons/calendar/models/calendar_attendee.py | 3 +- addons/digest/models/digest.py | 16 +++++-- addons/mail/models/mail_composer_mixin.py | 4 +- addons/mail/models/mail_render_mixin.py | 44 +++++++++++++------ addons/mail/models/mail_template.py | 7 +-- addons/mail/wizard/mail_compose_message.py | 6 ++- addons/mass_mailing/controllers/main.py | 8 +++- addons/mass_mailing/models/mailing.py | 7 +-- .../wizard/mailing_mailing_test.py | 7 +-- addons/sale/wizard/sale_order_cancel.py | 4 +- addons/survey/wizard/survey_invite.py | 2 +- .../wizard/slide_channel_invite.py | 2 +- 12 files changed, 74 insertions(+), 36 deletions(-) diff --git a/addons/calendar/models/calendar_attendee.py b/addons/calendar/models/calendar_attendee.py index 9df8051e061..0506fad587a 100644 --- a/addons/calendar/models/calendar_attendee.py +++ b/addons/calendar/models/calendar_attendee.py @@ -123,8 +123,7 @@ class Attendee(models.Model): body = mail_template._render_field( 'body_html', attendee.ids, - compute_lang=True, - post_process=True)[attendee.id] + compute_lang=True)[attendee.id] subject = mail_template._render_field( 'subject', attendee.ids, diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py index 735a78f4be3..354d3a7d59d 100644 --- a/addons/digest/models/digest.py +++ b/addons/digest/models/digest.py @@ -165,8 +165,10 @@ class Digest(models.Model): 'tips': self._compute_tips(user.company_id, user, tips_count=tips_count, consumed=consume_tips), 'preferences': self._compute_preferences(user.company_id, user), }, - post_process=True, - options={'preserve_comments': True} + options={ + 'preserve_comments': True, + 'post_process': True, + }, )[self.id] full_mail = self.env['mail.render.mixin']._render_encapsulate( 'digest.digest_mail_layout', @@ -288,7 +290,15 @@ class Digest(models.Model): '|', ('group_id', 'in', user.groups_id.ids), ('group_id', '=', False) ], limit=tips_count) tip_descriptions = [ - tools.html_sanitize(self.env['mail.render.mixin'].sudo()._render_template(tip.tip_description, 'digest.tip', tip.ids, post_process=True, engine="qweb")[tip.id]) + tools.html_sanitize( + self.env['mail.render.mixin'].sudo()._render_template( + tip.tip_description, + 'digest.tip', + tip.ids, + engine="qweb", + options={'post_process': True}, + )[tip.id] + ) for tip in tips ] if consumed: diff --git a/addons/mail/models/mail_composer_mixin.py b/addons/mail/models/mail_composer_mixin.py index 9619eae88f8..a17b5941199 100644 --- a/addons/mail/models/mail_composer_mixin.py +++ b/addons/mail/models/mail_composer_mixin.py @@ -22,7 +22,9 @@ class MailComposerMixin(models.AbstractModel): # Content subject = fields.Char('Subject', compute='_compute_subject', readonly=False, store=True) - body = fields.Html('Contents', compute='_compute_body', render_engine='qweb', store=True, readonly=False, sanitize=False) + body = fields.Html( + 'Contents', compute='_compute_body', readonly=False, store=True, + render_engine='qweb', render_options={'post_process': True}, sanitize=False) template_id = fields.Many2one('mail.template', 'Mail Template', domain="[('model', '=', render_model)]") # Access is_mail_template_editor = fields.Boolean('Is Editor', compute='_compute_is_mail_template_editor') diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index 75929065faa..cfec4651007 100644 --- a/addons/mail/models/mail_render_mixin.py +++ b/addons/mail/models/mail_render_mixin.py @@ -284,7 +284,8 @@ class MailRenderMixin(models.AbstractModel): :param dict add_context: additional context to give to renderer. It allows to add or update values to base rendering context generated by ``MailRenderMixin._render_eval_context()``; - :param dict options: options for rendering (not used currently); + :param dict options: options for rendering propagated to IrQweb render + (see docstring for available options); :return dict: {res_id: string of rendered template based on record} """ @@ -337,7 +338,8 @@ class MailRenderMixin(models.AbstractModel): :param dict add_context: additional context to give to renderer. It allows to add or update values to base rendering context generated by ``MailRenderMixin._render_eval_context()``; - :param dict options: options for rendering (not used currently); + :param dict options: options for rendering propagated to IrQweb render + (see docstring for available options); :return dict: {res_id: string of rendered template based on record} """ @@ -378,7 +380,8 @@ class MailRenderMixin(models.AbstractModel): :param dict add_context: additional context to give to renderer. It allows to add or update values to base rendering context generated by ``MailRenderMixin._render_inline_template_eval_context()``; - :param dict options: options for rendering; + :param dict options: options for rendering (no options available + currently); :return dict: {res_id: string of rendered template based on record} """ @@ -434,7 +437,7 @@ class MailRenderMixin(models.AbstractModel): @api.model def _render_template(self, template_src, model, res_ids, engine='inline_template', - add_context=None, options=None, post_process=False): + add_context=None, options=None): """ Render the given string on records designed by model / res_ids using the given rendering engine. Possible engine are small_web, qweb, or qweb_view. @@ -449,12 +452,20 @@ class MailRenderMixin(models.AbstractModel): :param dict add_context: additional context to give to renderer. It allows to add or update values to base rendering context generated by ``MailRenderMixin._render__eval_context()``; - :param dict options: options for rendering; - :param boolean post_process: perform a post processing on rendered result - (notably html links management). See``_render_template_postprocess``; + :param dict options: options for rendering. Use in this method and also + propagated to rendering sub-methods. May contain notably + + boolean post_process: perform a post processing on rendered result + (notably html links management). See``_render_template_postprocess``; + boolean preserve_comments: if set, comments are preserved. Default + behavior is to remove them. It is used notably for browser-specific + code implemented like comments; :return dict: {res_id: string of rendered template based on record} """ + if options is None: + options = {} + if not isinstance(res_ids, (list, tuple)): raise ValueError(_('Template rendering should be called only using on a list of IDs.')) if engine not in ('inline_template', 'qweb', 'qweb_view'): @@ -470,7 +481,7 @@ class MailRenderMixin(models.AbstractModel): rendered = self._render_template_inline_template(template_src, model, res_ids, add_context=add_context, options=options) - if post_process: + if options.get('post_process'): rendered = self._render_template_postprocess(rendered) return rendered @@ -523,7 +534,7 @@ class MailRenderMixin(models.AbstractModel): def _render_field(self, field, res_ids, engine='inline_template', compute_lang=False, set_lang=False, - add_context=None, options=None, post_process=False): + add_context=None, options=None): """ Given some record ids, render a template located on field on all records. ``field`` should be a field of self (i.e. ``body_html`` on ``mail.template``). res_ids are record IDs linked to ``model`` field @@ -542,9 +553,16 @@ class MailRenderMixin(models.AbstractModel): ``compute_lang`` is False; :param dict add_context: additional context to give to renderer; - :param dict options: options for rendering; - :param boolean post_process: perform a post processing on rendered result - (notably html links management). See``_render_template_postprocess``); + :param dict options: options for rendering. Use in this method and also + propagated to rendering sub-methods. Base values come from the field + (coming from ``render_options`` parameter) and are updated by this + optional dictionary. May contain notably + + boolean post_process: perform a post processing on rendered result + (notably html links management). See``_render_template_postprocess``; + boolean preserve_comments: if set, comments are preserved. Default + behavior is to remove them. It is used notably for browser-specific + code implemented like comments; :return dict: {res_id: string of rendered template based on record} """ @@ -568,7 +586,6 @@ class MailRenderMixin(models.AbstractModel): # rendering options engine = getattr(self._fields[field], 'render_engine', engine) options.update(**getattr(self._fields[field], 'render_options', {})) - post_process = options.get('post_process') or post_process return dict( (res_id, rendered) @@ -580,6 +597,5 @@ class MailRenderMixin(models.AbstractModel): engine=engine, add_context=add_context, options=options, - post_process=post_process ).items() ) diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 920647e98c7..7825c182cd4 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -56,7 +56,9 @@ class MailTemplate(models.Model): email_cc = fields.Char('Cc', help="Carbon copy recipients (placeholders may be used here)") reply_to = fields.Char('Reply To', help="Email address to which replies will be redirected when sending emails in mass; only used when the reply is not logged in the original discussion thread.") # content - body_html = fields.Html('Body', render_engine='qweb', translate=True, prefetch=True, sanitize=False) + body_html = fields.Html( + 'Body', render_engine='qweb', render_options={'post_process': True}, + prefetch=True, translate=True, sanitize=False) attachment_ids = fields.Many2many('ir.attachment', 'email_template_attachment_rel', 'email_template_id', 'attachment_id', 'Attachments', help="You may attach files to this template, to be added to all " @@ -246,8 +248,7 @@ class MailTemplate(models.Model): for lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items(): for field in fields: generated_field_values = template._render_field( - field, template_res_ids, - post_process=(field == 'body_html') + field, template_res_ids ) for res_id, field_value in generated_field_values.items(): results.setdefault(res_id, dict())[field] = field_value diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 3469f43d754..8b69f6a0f36 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -89,7 +89,9 @@ class MailComposer(models.TransientModel): # content subject = fields.Char('Subject', compute=False) - body = fields.Html('Contents', render_engine='qweb', compute=False, default='', sanitize_style=True) + body = fields.Html( + 'Contents', render_engine='qweb', render_options={'post_process': True}, + compute=False, default='', sanitize_style=True) parent_id = fields.Many2one( 'mail.message', 'Parent Message', ondelete='set null') template_id = fields.Many2one('mail.template', 'Use template', domain="[('model', '=', model)]") @@ -658,7 +660,7 @@ class MailComposer(models.TransientModel): subjects = self._render_field('subject', res_ids) # We want to preserve comments in emails so as to keep mso conditionals - bodies = self._render_field('body', res_ids, post_process=True, options={'preserve_comments': self.composition_mode == 'mass_mail'}) + bodies = self._render_field('body', res_ids, options={'preserve_comments': self.composition_mode == 'mass_mail'}) emails_from = self._render_field('email_from', res_ids) replies_to = self._render_field('reply_to', res_ids) default_recipients = {} diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py index 05401375eb1..2b3cfd32fc7 100644 --- a/addons/mass_mailing/controllers/main.py +++ b/addons/mass_mailing/controllers/main.py @@ -167,7 +167,13 @@ class MassMailController(http.Controller): if not self._valid_unsubscribe_token(mailing_id, res_id, email, str(token)) and not request.env.user.has_group('mass_mailing.group_mass_mailing_user'): raise exceptions.AccessDenied() - html_markupsafe = mailing._render_field('body_html', [res_id])[res_id] + # do not force lang, will simply use user context + html_markupsafe = mailing._render_field( + 'body_html', + [res_id], + compute_lang=False, + options={'post_process': False} + )[res_id] # Update generic URLs (without parameters) to final ones html_markupsafe = html_markupsafe.replace('/unsubscribe_from_list', mailing._get_unsubscribe_url(email, res_id)) diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py index 4fc7b40989a..d95f295c1b3 100644 --- a/addons/mass_mailing/models/mailing.py +++ b/addons/mass_mailing/models/mailing.py @@ -73,6 +73,7 @@ class MassMailing(models.Model): 'Subject', required=True, translate=False) preview = fields.Char( 'Preview', translate=False, + render_engine='inline_template', render_options={'post_process': True}, help='Catchy preview sentence that encourages recipients to open this email.\n' 'In most inboxes, this is displayed next to the subject.\n' 'Keep it empty if you prefer the first characters of your email content to appear instead.') @@ -104,9 +105,9 @@ class MassMailing(models.Model): help="Date at which the mailing was or will be sent.") # don't translate 'body_arch', the translations are only on 'body_html' body_arch = fields.Html(string='Body', translate=False, sanitize=False) - body_html = fields.Html(string='Body converted to be sent by mail', render_engine='qweb', sanitize=False) - - # used to determine if the mail body is empty + body_html = fields.Html( + string='Body converted to be sent by mail', sanitize=False, + render_engine='qweb', render_options={'post_process': True}) is_body_empty = fields.Boolean(compute="_compute_is_body_empty") attachment_ids = fields.Many2many( 'ir.attachment', 'mass_mailing_ir_attachments_rel', diff --git a/addons/mass_mailing/wizard/mailing_mailing_test.py b/addons/mass_mailing/wizard/mailing_mailing_test.py index 0006295a9ca..b00684fb8c6 100644 --- a/addons/mass_mailing/wizard/mailing_mailing_test.py +++ b/addons/mass_mailing/wizard/mailing_mailing_test.py @@ -37,10 +37,11 @@ class TestMassMailing(models.TransientModel): # Downside: Qweb syntax is only tested when there is atleast one record of the mailing's model if record: # Returns a proper error if there is a syntax error with Qweb - body = mailing._render_field('body_html', record.ids, post_process=True)[record.id] - preview = mailing._render_field('preview', record.ids, post_process=True)[record.id] + # do not force lang, will simply use user context + body = mailing._render_field('body_html', record.ids, compute_lang=False)[record.id] + preview = mailing._render_field('preview', record.ids, compute_lang=False)[record.id] full_body = mailing._prepend_preview(Markup(body), preview) - subject = mailing._render_field('subject', record.ids)[record.id] + subject = mailing._render_field('subject', record.ids, compute_lang=False)[record.id] else: full_body = mailing._prepend_preview(mailing.body_html, mailing.preview) subject = mailing.subject diff --git a/addons/sale/wizard/sale_order_cancel.py b/addons/sale/wizard/sale_order_cancel.py index ab445a81758..ac4eb391f90 100644 --- a/addons/sale/wizard/sale_order_cancel.py +++ b/addons/sale/wizard/sale_order_cancel.py @@ -66,7 +66,7 @@ class SaleOrderCancel(models.TransientModel): wizard.template_id.subject, 'sale.order', [wizard.order_id.id], - post_process=True, + options={'post_process': True}, )[wizard.order_id.id] @api.depends('order_id') @@ -77,7 +77,7 @@ class SaleOrderCancel(models.TransientModel): wizard.template_id.body_html, 'sale.order', [wizard.order_id.id], - post_process=True, + options={'post_process': True}, engine='qweb', )[wizard.order_id.id] diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index f0814ea8bf3..9c7e89514b6 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -210,7 +210,7 @@ class SurveyInvite(models.TransientModel): def _send_mail(self, answer): """ Create mail specific for recipient containing notably its access token """ subject = self._render_field('subject', answer.ids)[answer.id] - body = self._render_field('body', answer.ids, post_process=True)[answer.id] + body = self._render_field('body', answer.ids)[answer.id] # post the message mail_values = { 'attachment_ids': [(4, att.id) for att in self.attachment_ids], diff --git a/addons/website_slides/wizard/slide_channel_invite.py b/addons/website_slides/wizard/slide_channel_invite.py index be8ef6490c2..d4e32cfbcd8 100644 --- a/addons/website_slides/wizard/slide_channel_invite.py +++ b/addons/website_slides/wizard/slide_channel_invite.py @@ -67,7 +67,7 @@ class SlideChannelInvite(models.TransientModel): def _prepare_mail_values(self, slide_channel_partner): """ Create mail specific for recipient """ subject = self._render_field('subject', slide_channel_partner.ids)[slide_channel_partner.id] - body = self._render_field('body', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] + body = self._render_field('body', slide_channel_partner.ids)[slide_channel_partner.id] # post the message mail_values = { 'email_from': self.env.user.email_formatted, From 0144dc9201a8a1e5bdd2992382ff2a3a52ed4717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 21 Nov 2022 11:57:49 +0000 Subject: [PATCH 4/5] [IMP] mail: give priority to asked options when rendering values When calling ``_render_fields`` asked options are now considered as more important compared to options defined in field itself (using render_options). Purpose is to allow to force some options when necessary. Task-2710804 (Mail: Clean MailThread API) Part-of: odoo/odoo#106072 --- addons/mail/models/mail_render_mixin.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index cfec4651007..63da0106246 100644 --- a/addons/mail/models/mail_render_mixin.py +++ b/addons/mail/models/mail_render_mixin.py @@ -583,9 +583,11 @@ class MailRenderMixin(models.AbstractModel): else: templates_res_ids = {self._context.get('lang'): (self, res_ids)} - # rendering options + # rendering options (update default defined on field by asked options) engine = getattr(self._fields[field], 'render_engine', engine) - options.update(**getattr(self._fields[field], 'render_options', {})) + field_options = getattr(self._fields[field], 'render_options', {}) + if options: + field_options.update(**options) return dict( (res_id, rendered) @@ -596,6 +598,6 @@ class MailRenderMixin(models.AbstractModel): tpl_res_ids, engine=engine, add_context=add_context, - options=options, + options=field_options, ).items() ) From be007dd36a335bbcd96ddc8d7187882a765aed6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 21 Nov 2022 08:41:42 +0000 Subject: [PATCH 5/5] [REF] sale: improve usage of composer mixin in order cancel We can call ``_render_field`` on template model directly. In this commit we also add the dynamic language computation through ``_compute_lang``. It will use the field defined in template. Task-2710804 (Mail: Clean MailThread API) Part-of: odoo/odoo#106072 --- addons/sale/wizard/sale_order_cancel.py | 31 +++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/addons/sale/wizard/sale_order_cancel.py b/addons/sale/wizard/sale_order_cancel.py index ac4eb391f90..96f1c55633a 100644 --- a/addons/sale/wizard/sale_order_cancel.py +++ b/addons/sale/wizard/sale_order_cancel.py @@ -60,26 +60,23 @@ class SaleOrderCancel(models.TransientModel): @api.depends('order_id') def _compute_subject(self): - for wizard in self: - if wizard.template_id: - wizard.subject = self.sudo()._render_template( - wizard.template_id.subject, - 'sale.order', - [wizard.order_id.id], - options={'post_process': True}, - )[wizard.order_id.id] + for wizard_su in self.filtered('template_id').sudo(): + wizard_su.subject = wizard_su.template_id._render_field( + 'subject', + [wizard_su.order_id.id], + compute_lang=True, + options={'post_process': True}, + )[wizard_su.order_id.id] @api.depends('order_id') def _compute_body(self): - for wizard in self: - if wizard.template_id: - wizard.body = self.sudo()._render_template( - wizard.template_id.body_html, - 'sale.order', - [wizard.order_id.id], - options={'post_process': True}, - engine='qweb', - )[wizard.order_id.id] + for wizard_su in self.filtered('template_id').sudo(): + wizard_su.body = wizard_su.template_id._render_field( + 'body_html', + [wizard_su.order_id.id], + compute_lang=True, + options={'post_process': True}, + )[wizard_su.order_id.id] def action_send_mail_and_cancel(self): self.ensure_one()