Purpose ======= Purpose of this commit is to add a new group for the mail template designer. Goal is to make roles clearer: managers edit templates, users use them. This commit allow some designers / managers to make email template and to let others users use those email templates. Specifications ============== When this feature is enabled in the Settings page, a new group is required to modify email templates in a composer like wizard or to make dynamic content. This allows to separate managers editing / composing templates from standard users that use them. If the current does not have this group, the email body will be in readonly mode if he selected an email template. That way we force him to use the email template that the manager made. Technical ========= New Group --------- Only users in this group will be able to create / write email template or to write Jinja code in the mail composer (including other fields like subject in mailing). By default, all internal users have this group. Mass mailing users also have this group as writing mailings is about the same management level as writing templates. Mail Composer Mixin ------------------- In comment mode, the template is rendered and then saved on the body field so non-"Mail Template Editor" users can load email templates. But in mass mode, the body of the template is saved and then rendered and many things change the body (HTML sanitizer, web editor move inline CSS properties, add / remove spaces...). So in this case, we can not know if the user changed the body or not. That is why we put the body field in readonly mode so, it is not modified by the web editor. Jinja code detection -------------------- To detect dynamic Jinja content, we compile the template, and we browse the AST. If we do not have a single "Template Data" node, we assume that the template is dynamic. When we detect the template as static, we do not render it. That way we avoid unnecessary rendering. Code cleaning ------------- Move Jinja import into tools so that it is outside of mail framework code. Task-2187263 closes odoo/odoo#75840 Related: odoo/enterprise#20547 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
392 lines
15 KiB
Python
392 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo.addons.mail.tests import common
|
|
from odoo.exceptions import AccessError
|
|
from odoo.tests import tagged, users
|
|
|
|
|
|
@tagged('mail_render')
|
|
class TestMailRender(common.MailCommon):
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(TestMailRender, cls).setUpClass()
|
|
|
|
# activate multi language support
|
|
cls.env['res.lang']._activate_lang('fr_FR')
|
|
cls.user_admin.write({'lang': 'en_US'})
|
|
|
|
# test records
|
|
cls.render_object = cls.env['res.partner'].create({
|
|
'name': 'TestRecord',
|
|
'lang': 'en_US',
|
|
})
|
|
cls.render_object_fr = cls.env['res.partner'].create({
|
|
'name': 'Element de Test',
|
|
'lang': 'fr_FR',
|
|
})
|
|
|
|
# some jinja templates
|
|
cls.base_jinja_bits = [
|
|
'<p>Hello</p>',
|
|
'<p>Hello ${object.name}</p>',
|
|
"""<p>
|
|
% set english = object.lang == 'en_US'
|
|
% if english
|
|
<span>English Speaker</span>
|
|
% else
|
|
<span>Other Speaker</span>
|
|
% endif
|
|
</p>""",
|
|
"""
|
|
<p>${13 + 13}</p>
|
|
<h1>This is a test</h1>
|
|
""",
|
|
"""
|
|
<b>Test</b>
|
|
% if False:
|
|
<b>Code not executed</b>
|
|
% endif
|
|
""",
|
|
]
|
|
cls.base_jinja_bits_fr = [
|
|
'<p>Bonjour</p>',
|
|
'<p>Bonjour ${object.name}</p>',
|
|
"""<p>
|
|
% set english = object.lang == 'en_US'
|
|
% if english
|
|
<span>Narrateur Anglais</span>
|
|
% else
|
|
<span>Autre Narrateur</span>
|
|
% endif
|
|
</p>"""
|
|
]
|
|
|
|
# some qweb templates, their views and their xml ids
|
|
cls.base_qweb_bits = [
|
|
'<p>Hello</p>',
|
|
'<p>Hello <t t-esc="object.name"/></p>',
|
|
"""<p>
|
|
<span t-if="object.lang == 'en_US'">English Speaker</span>
|
|
<span t-else="">Other Speaker</span>
|
|
</p>"""
|
|
]
|
|
cls.base_qweb_templates = cls.env['ir.ui.view'].create([
|
|
{'name': 'TestRender%d' % index,
|
|
'type': 'qweb',
|
|
'arch': qweb_content,
|
|
} for index, qweb_content in enumerate(cls.base_qweb_bits)
|
|
])
|
|
cls.base_qweb_templates_data = cls.env['ir.model.data'].create([
|
|
{'name': template.name, 'module': 'mail',
|
|
'model': template._name, 'res_id': template.id,
|
|
} for template in cls.base_qweb_templates
|
|
])
|
|
cls.base_qweb_templates_xmlids = [
|
|
model_data.complete_name
|
|
for model_data in cls.base_qweb_templates_data
|
|
]
|
|
|
|
# render result
|
|
cls.base_rendered = [
|
|
'<p>Hello</p>',
|
|
'<p>Hello %s</p>' % cls.render_object.name,
|
|
"""<p>
|
|
<span>English Speaker</span>
|
|
</p>"""
|
|
]
|
|
cls.base_rendered_fr = [
|
|
'<p>Bonjour</p>',
|
|
'<p>Bonjour %s</p>' % cls.render_object_fr.name,
|
|
"""<p>
|
|
<span>Autre Narrateur</span>
|
|
</p>"""
|
|
]
|
|
|
|
# link to mail template
|
|
cls.test_template_jinja = cls.env['mail.template'].create({
|
|
'name': 'Test Template',
|
|
'subject': cls.base_jinja_bits[0],
|
|
'body_html': cls.base_jinja_bits[1],
|
|
'model_id': cls.env['ir.model']._get('res.partner').id,
|
|
'lang': '${object.lang}'
|
|
})
|
|
|
|
# some translations
|
|
cls.env['ir.translation'].create({
|
|
'type': 'model',
|
|
'name': 'mail.template,subject',
|
|
'lang': 'fr_FR',
|
|
'res_id': cls.test_template_jinja.id,
|
|
'src': cls.test_template_jinja.subject,
|
|
'value': cls.base_jinja_bits_fr[0],
|
|
})
|
|
cls.env['ir.translation'].create({
|
|
'type': 'model',
|
|
'name': 'mail.template,body_html',
|
|
'lang': 'fr_FR',
|
|
'res_id': cls.test_template_jinja.id,
|
|
'src': cls.test_template_jinja.body_html,
|
|
'value': cls.base_jinja_bits_fr[1],
|
|
})
|
|
cls.env['ir.model.data'].create({
|
|
'name': 'test_template_xmlid',
|
|
'module': 'mail',
|
|
'model': cls.test_template_jinja._name,
|
|
'res_id': cls.test_template_jinja.id,
|
|
})
|
|
|
|
# Enable group-based template management
|
|
cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', True)
|
|
|
|
# User without the group "mail.group_mail_template_editor"
|
|
cls.user_rendering_restricted = common.mail_new_test_user(
|
|
cls.env, login='user_rendering_restricted',
|
|
groups='base.group_user',
|
|
company_id=cls.company_admin.id,
|
|
name='Jinja Restricted User',
|
|
notification_type='inbox',
|
|
signature='--\nErnest'
|
|
)
|
|
cls.user_rendering_restricted.groups_id -= cls.env.ref('mail.group_mail_template_editor')
|
|
|
|
@users('employee')
|
|
def test_evaluation_context(self):
|
|
""" Test evaluation context and various ways of tweaking it. """
|
|
partner = self.env['res.partner'].browse(self.render_object.ids)
|
|
MailRenderMixin = self.env['mail.render.mixin']
|
|
|
|
custom_ctx = {'custom_ctx': 'Custom Context Value'}
|
|
add_context = {
|
|
'custom_value': 'Custom Render Value'
|
|
}
|
|
srces = [
|
|
'<b>I am ${user.name}</b>',
|
|
'<span>Datetime is ${format_datetime(datetime.datetime(2021, 6, 1), dt_format="MM - d - YYY")}</span>',
|
|
'<span>Context ${ctx.get("custom_ctx")}, value ${custom_value}</span>',
|
|
]
|
|
results = [
|
|
'<b>I am %s</b>' % self.env.user.name,
|
|
'<span>Datetime is 06 - 1 - 2021</span>',
|
|
'<span>Context Custom Context Value, value Custom Render Value</span>'
|
|
]
|
|
for src, expected in zip(srces, results):
|
|
for engine in ['jinja']:
|
|
result = MailRenderMixin.with_context(**custom_ctx)._render_template(
|
|
src, partner._name, partner.ids,
|
|
engine=engine, add_context=add_context
|
|
)[partner.id]
|
|
self.assertEqual(expected, result)
|
|
|
|
@users('employee')
|
|
def test_render_mail_template_jinja(self):
|
|
template = self.env['mail.template'].browse(self.test_template_jinja.ids)
|
|
partner = self.env['res.partner'].browse(self.render_object.ids)
|
|
for fname, expected in zip(['subject', 'body_html'], self.base_rendered):
|
|
rendered = template._render_field(
|
|
fname,
|
|
partner.ids,
|
|
compute_lang=True
|
|
)[partner.id]
|
|
self.assertEqual(rendered, expected)
|
|
|
|
partner = self.env['res.partner'].browse(self.render_object_fr.ids)
|
|
for fname, expected in zip(['subject', 'body_html'], self.base_rendered_fr):
|
|
rendered = template._render_field(
|
|
fname,
|
|
partner.ids,
|
|
compute_lang=True
|
|
)[partner.id]
|
|
self.assertEqual(rendered, expected)
|
|
|
|
@users('employee')
|
|
def test_render_template_jinja(self):
|
|
partner = self.env['res.partner'].browse(self.render_object.ids)
|
|
for source, expected in zip(self.base_jinja_bits, self.base_rendered):
|
|
rendered = self.env['mail.render.mixin']._render_template(
|
|
source,
|
|
partner._name,
|
|
partner.ids,
|
|
engine='jinja',
|
|
)[partner.id]
|
|
self.assertEqual(rendered, expected)
|
|
|
|
@users('employee')
|
|
def test_render_template_qweb_view(self):
|
|
partner = self.env['res.partner'].browse(self.render_object.ids)
|
|
for source, expected in zip(self.base_qweb_templates_xmlids, self.base_rendered):
|
|
rendered = self.env['mail.render.mixin']._render_template(
|
|
source,
|
|
partner._name,
|
|
partner.ids,
|
|
engine='qweb_view',
|
|
)[partner.id]
|
|
self.assertEqual(rendered, expected)
|
|
|
|
@users('employee')
|
|
def test_template_rendering_impersonate(self):
|
|
""" Test that the use of SUDO do not change the current user. """
|
|
partner = self.env['res.partner'].browse(self.render_object.ids)
|
|
src = '${user.name} - ${object.name}'
|
|
expected = '%s - %s' % (self.env.user.name, partner.name)
|
|
result = self.env['mail.render.mixin'].sudo()._render_template_jinja(
|
|
src, partner._name, partner.ids
|
|
)[partner.id]
|
|
self.assertIn(expected, result)
|
|
|
|
@users('user_rendering_restricted')
|
|
def test_template_rendering_function_call(self):
|
|
"""Test the case when the template call a custom function.
|
|
|
|
This function should not be called when the template is not rendered.
|
|
"""
|
|
model = 'res.partner'
|
|
res_ids = self.env[model].search([], limit=1).ids
|
|
partner = self.env[model].browse(res_ids)
|
|
MailRenderMixin = self.env['mail.render.mixin']
|
|
|
|
def cust_function():
|
|
# Can not use "MagicMock" in a Jinja sand-boxed environment
|
|
# so create our own function
|
|
cust_function.call = True
|
|
return 'return value'
|
|
|
|
cust_function.call = False
|
|
|
|
src = """<h1>This is a test</h1>
|
|
<p>${cust_function()}</p>"""
|
|
expected = """<h1>This is a test</h1>
|
|
<p>return value</p>"""
|
|
context = {'cust_function': cust_function}
|
|
|
|
result = self.env['mail.render.mixin'].with_user(self.user_admin)._render_template_jinja(
|
|
src, partner._name, partner.ids,
|
|
add_context=context
|
|
)[partner.id]
|
|
self.assertEqual(expected, result)
|
|
self.assertTrue(cust_function.call)
|
|
|
|
with self.assertRaises(AccessError, msg='Simple user should not be able to render Jinja code'):
|
|
MailRenderMixin._render_template_jinja(src, model, res_ids, add_context=context)
|
|
|
|
@users('user_rendering_restricted')
|
|
def test_template_render_static(self):
|
|
"""Test that we render correctly static templates (without placeholders)."""
|
|
model = 'res.partner'
|
|
res_ids = self.env[model].search([], limit=1).ids
|
|
MailRenderMixin = self.env['mail.render.mixin']
|
|
|
|
result = MailRenderMixin._render_template_jinja(self.base_jinja_bits[0], model, res_ids)[res_ids[0]]
|
|
self.assertEqual(result, self.base_jinja_bits[0])
|
|
|
|
@users('user_rendering_restricted')
|
|
def test_template_rendering_restricted(self):
|
|
"""Test if we correctly detect static template."""
|
|
res_ids = self.env['res.partner'].search([], limit=1).ids
|
|
with self.assertRaises(AccessError, msg='Simple user should not be able to render Jinja code'):
|
|
self.env['mail.render.mixin']._render_template_jinja(self.base_jinja_bits[3], 'res.partner', res_ids)
|
|
|
|
@users('employee')
|
|
def test_template_rendering_unrestricted(self):
|
|
"""Test if we correctly detect static template."""
|
|
res_ids = self.env['res.partner'].search([], limit=1).ids
|
|
result = self.env['mail.render.mixin']._render_template_jinja(self.base_jinja_bits[3], 'res.partner', res_ids)[res_ids[0]]
|
|
self.assertIn('26', result, 'Template Editor should be able to render Jinja code')
|
|
|
|
@users('employee')
|
|
def test_template_rendering_various(self):
|
|
""" Test static rendering """
|
|
partner = self.env['res.partner'].browse(self.render_object.ids)
|
|
MailRenderMixin = self.env['mail.render.mixin']
|
|
|
|
# static string
|
|
src = 'This is a string'
|
|
expected = 'This is a string'
|
|
for engine in ['jinja']:
|
|
result = MailRenderMixin._render_template(
|
|
src, partner._name, partner.ids, engine=engine,
|
|
)[partner.id]
|
|
self.assertEqual(expected, result)
|
|
|
|
# code string
|
|
src = 'This is a string with a number ${13+13}'
|
|
expected = 'This is a string with a number 26'
|
|
for engine in ['jinja']:
|
|
result = MailRenderMixin._render_template(
|
|
src, partner._name, partner.ids, engine=engine,
|
|
)[partner.id]
|
|
self.assertEqual(expected, result)
|
|
|
|
# block string
|
|
src = "This is a string with a block ${'hidden' if False else 'displayed'}"
|
|
expected = 'This is a string with a block displayed'
|
|
for engine in ['jinja']:
|
|
result = MailRenderMixin._render_template(
|
|
src, partner._name, partner.ids, engine=engine,
|
|
)[partner.id]
|
|
self.assertEqual(expected, result)
|
|
|
|
# static xml
|
|
src = '<p class="text-muted"><span>This is a string</span></p>'
|
|
expected = '<p class="text-muted"><span>This is a string</span></p>'
|
|
for engine in ['jinja', 'qweb']:
|
|
result = MailRenderMixin._render_template(
|
|
src, partner._name, partner.ids, engine=engine,
|
|
)[partner.id]
|
|
self.assertEqual(expected, result if engine == 'jinja' else result) # tde: checkme
|
|
|
|
# code xml
|
|
srces = [
|
|
'<p class="text-muted"><span>This is a string with a number ${13+13}</span></p>',
|
|
'<p class="text-muted"><span>This is a string with a number <t t-out="13+13"/></span></p>',
|
|
]
|
|
expected = '<p class="text-muted"><span>This is a string with a number 26</span></p>'
|
|
for engine, src in zip(['jinja', 'qweb'], srces):
|
|
result = MailRenderMixin._render_template(
|
|
src, partner._name, partner.ids, engine=engine,
|
|
)[partner.id]
|
|
self.assertEqual(expected, result if engine == 'jinja' else result) # tde: checkme
|
|
|
|
# condition block xml
|
|
src = """<b>Test</b>
|
|
% if False:
|
|
<b>Code not executed</b>
|
|
% endif"""
|
|
expected = "<b>Test</b>\n" # TDE: to check
|
|
for engine in ['jinja']:
|
|
result = MailRenderMixin._render_template(
|
|
src, partner._name, partner.ids, engine=engine,
|
|
)[partner.id]
|
|
self.assertEqual(expected, result)
|
|
|
|
# complete block xml
|
|
src = """<p>
|
|
% set line_statement_variable = 3
|
|
<span>We have ${line_statement_variable} cookies in stock</span>
|
|
<span>We have <% set block_variable = 4 %>${block_variable} cookies in stock</span>
|
|
</p>"""
|
|
expected = """<p>
|
|
<span>We have 3 cookies in stock</span>
|
|
<span>We have 4 cookies in stock</span>
|
|
</p>"""
|
|
for engine in ['jinja']:
|
|
result = MailRenderMixin._render_template(
|
|
src, partner._name, partner.ids, engine=engine,
|
|
)[partner.id]
|
|
self.assertEqual(result, expected)
|
|
|
|
@users('user_rendering_restricted')
|
|
def test_is_jinja_template_condition_block_restricted(self):
|
|
"""Test if we correctly detect condition block (which might contains code)."""
|
|
res_ids = self.env['res.partner'].search([], limit=1).ids
|
|
with self.assertRaises(AccessError, msg='Simple user should not be able to render Jinja code'):
|
|
self.env['mail.render.mixin']._render_template_jinja(self.base_jinja_bits[4], 'res.partner', res_ids)
|
|
|
|
@users('employee')
|
|
def test_is_jinja_template_condition_block_unrestricted(self):
|
|
"""Test if we correctly detect condition block (which might contains code)."""
|
|
res_ids = self.env['res.partner'].search([], limit=1).ids
|
|
result = self.env['mail.render.mixin']._render_template_jinja(self.base_jinja_bits[4], 'res.partner', res_ids)[res_ids[0]]
|
|
self.assertNotIn('Code not executed', result, 'The condition block did not work')
|