Files
odoo_source/addons/mail/tests/test_mail_render.py
T
std-odoo cc012a0864 [IMP] mail, various: add email templates management levels
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>
2021-09-02 03:42:54 +00:00

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')