diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py
index 30dc4a4fdb0..d5a282adcf4 100644
--- a/addons/digest/models/digest.py
+++ b/addons/digest/models/digest.py
@@ -121,7 +121,7 @@ class Digest(models.Model):
'digest.digest_mail_main',
'digest.digest',
self.ids,
- engine='qweb',
+ engine='qweb_view',
add_context={
'title': self.name,
'top_button_label': _('Connect'),
diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py
index 1f4b97c5ab4..42e1261273d 100644
--- a/addons/mail/models/mail_render_mixin.py
+++ b/addons/mail/models/mail_render_mixin.py
@@ -37,6 +37,29 @@ def format_time(env, time, tz=False, time_format='medium', lang_code=False):
except babel.core.UnknownLocaleError:
return time
+def relativedelta_proxy(*args, **kwargs):
+ # dateutil.relativedelta is an old-style class and cannot be directly
+ # instanciated wihtin a jinja2 expression, so a lambda "proxy" is
+ # is needed, apparently
+ return relativedelta.relativedelta(*args, **kwargs)
+
+template_env_globals = {
+ 'str': str,
+ 'quote': urls.url_quote,
+ 'urlencode': urls.url_encode,
+ 'datetime': safe_eval.datetime,
+ 'len': len,
+ 'abs': abs,
+ 'min': min,
+ 'max': max,
+ 'sum': sum,
+ 'filter': filter,
+ 'reduce': functools.reduce,
+ 'map': map,
+ 'relativedelta': relativedelta_proxy,
+ 'round': round,
+}
+
try:
# We use a jinja2 sandboxed environment to render mako templates.
# Note that the rendering does not cover all the mako syntax, in particular
@@ -58,26 +81,7 @@ try:
trim_blocks=True, # do not output newline after blocks
autoescape=True, # XML/HTML automatic escaping
)
- jinja_template_env.globals.update({
- 'str': str,
- 'quote': urls.url_quote,
- 'urlencode': urls.url_encode,
- 'datetime': safe_eval.datetime,
- 'len': len,
- 'abs': abs,
- 'min': min,
- 'max': max,
- 'sum': sum,
- 'filter': filter,
- 'reduce': functools.reduce,
- 'map': map,
- 'round': round,
-
- # dateutil.relativedelta is an old-style class and cannot be directly
- # instanciated wihtin a jinja2 expression, so a lambda "proxy" is
- # is needed, apparently.
- 'relativedelta': lambda *a, **kw : relativedelta.relativedelta(*a, **kw),
- })
+ jinja_template_env.globals.update(template_env_globals)
jinja_safe_template_env = copy.copy(jinja_template_env)
jinja_safe_template_env.autoescape = False
except ImportError:
@@ -94,6 +98,8 @@ class MailRenderMixin(models.AbstractModel):
help="Optional translation language (ISO code) to select when sending out an email. "
"If not set, the english version will be used. This should usually be a placeholder expression "
"that provides the appropriate language, e.g. ${object.partner_id.lang}.")
+ # rendering context
+ render_model = fields.Char("Rendering Model", compute='_compute_render_model', store=False)
# expression builder
model_object_field = fields.Many2one(
'ir.model.fields', string="Field", store=False,
@@ -114,6 +120,12 @@ class MailRenderMixin(models.AbstractModel):
'Placeholder Expression', store=False,
help="Final placeholder expression, to be copy-pasted in the desired template field.")
+ def _compute_render_model(self):
+ """ Give the target model for rendering. Void by default as models
+ inheriting from ``mail.render.mixin`` should define how to find this
+ model. """
+ self.render_model = False
+
@api.onchange('model_object_field', 'sub_model_object_field', 'null_value')
def _onchange_dynamic_placeholder(self):
""" Generate the dynamic placeholder """
@@ -243,38 +255,56 @@ class MailRenderMixin(models.AbstractModel):
# ------------------------------------------------------------
@api.model
- def _render_qweb_eval_context(self):
- """ Prepare qweb evaluation context, containing for all rendering
+ def _get_common_eval_context(self):
+ """ Evaluation context used in all rendering engines. Contains
* ``user``: current user browse record;
* ``ctx```: current context;
* various formatting tools;
"""
- render_context = {
+ return {
'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code),
'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code),
+ 'format_time': lambda time, tz=False, time_format=False, lang_code=False: format_time(self.env, time, tz, time_format, lang_code),
'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code),
'format_duration': lambda value: tools.format_duration(value),
'user': self.env.user,
'ctx': self._context,
}
+
+ @api.model
+ def _render_qweb_eval_context(self):
+ """ Prepare qweb evaluation context, containing common context with
+ some specific values for Qweb. """
+ render_context = self._get_common_eval_context()
+ render_context.update(copy.copy(template_env_globals))
return render_context
@api.model
- def _render_template_qweb(self, template_src, model, res_ids, add_context=None):
- """ Render a QWeb template.
+ def _render_template_qweb_view(self, template_src, model, res_ids,
+ add_context=None, options=None):
+ """ Render a QWeb template based on an ir.ui.view content.
+
+ In addition to the generic evaluation context available, some other
+ variables are added:
+ * ``object``: record based on which the template is rendered;
:param str template_src: source QWeb template. It should be a string
- XmlID allowing to fetch an ir.ui.view;
- :param str model: see ``MailRenderMixin._render_field)``;
- :param list res_ids: see ``MailRenderMixin._render_field)``;
+ XmlID allowing to fetch an ``ir.ui.view``;
+ :param str model: see ``MailRenderMixin._render_template()``;
+ :param list res_ids: see ``MailRenderMixin._render_template()``;
:param dict add_context: additional context to give to renderer. It
- allows to add values to base rendering context generated by
- ``MailRenderMixin._render_qweb_eval_context()``;
+ allows to add or update values to base rendering context generated
+ by ``MailRenderMixin._render_qweb_eval_context()``;
+ :param dict options: options for rendering (not used currently);
: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.'))
+
view = self.env.ref(template_src, raise_if_not_found=False) or self.env['ir.ui.view']
results = dict.fromkeys(res_ids, u"")
if not view:
@@ -284,56 +314,55 @@ class MailRenderMixin(models.AbstractModel):
variables = self._render_qweb_eval_context()
if add_context:
variables.update(**add_context)
+ safe_eval.check_values(variables)
for record in self.env[model].browse(res_ids):
variables['object'] = record
try:
render_result = view._render(variables, engine='ir.qweb', minimal_qcontext=True)
except Exception as e:
- _logger.info("Failed to render template : %s (%d)" % (template_src, view.id), exc_info=True)
- raise UserError(_("Failed to render template : %s (%d)", template_src, view.id))
+ _logger.info("Failed to render template : %s (%d)", template_src, view.id, exc_info=True)
+ raise UserError(_("Failed to render template : %(xml_id)s (%(view_id)d)",
+ xml_id=template_src,
+ view_id=view.id))
results[record.id] = render_result
return results
@api.model
def _render_jinja_eval_context(self):
- """ Prepare jinja evaluation context, containing for all rendering
-
- * ``user``: current user browse record;
- * ``ctx```: current context, named ctx to avoid clash with jinja
- internals that already uses context;
- * various formatting tools;
- """
- render_context = {
- 'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code),
- 'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code),
- 'format_time': lambda time, tz=False, time_format=False, lang_code=False: format_time(self.env, time, tz, time_format, lang_code),
- 'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code),
- 'format_duration': lambda value: tools.format_duration(value),
- 'user': self.env.user,
- 'ctx': self._context,
- }
- return render_context
+ return self._get_common_eval_context()
@api.model
- def _render_template_jinja(self, template_txt, model, res_ids, add_context=None):
+ def _render_template_jinja(self, template_txt, model, res_ids,
+ add_context=None, options=None):
""" Render a string-based template on records given by a model and a list
of IDs, using jinja.
- In addition to the generic evaluation context given by _render_jinja_eval_context
- some new variables are added, depending on each record
-
+ In addition to the generic evaluation context available, some other
+ variables are added:
* ``object``: record based on which the template is rendered;
:param str template_txt: template text to render
- :param str model: model name of records on which we want to perform rendering
- :param list res_ids: list of ids of records (all belonging to same model)
+ :param str model: see ``MailRenderMixin._render_template()``;
+ :param list res_ids: see ``MailRenderMixin._render_template()``;
+
+ :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_jinja_eval_context()``;
+ :param dict options: options for rendering;
:return dict: {res_id: string of rendered template based on record}
"""
- # TDE FIXME: remove that brol (6dde919bb9850912f618b561cd2141bffe41340c)
- no_autoescape = self._context.get('safe')
+ # 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.'))
+
+ # TDE note: support 'safe' context key as backward compatibility for 6dde919bb9850912f618b561cd2141bffe41340c
+ if options is None:
+ options = {}
+ no_autoescape = options.get('render_safe') or self._context.get('safe')
+
results = dict.fromkeys(res_ids, u"")
if not template_txt:
return results
@@ -352,17 +381,13 @@ class MailRenderMixin(models.AbstractModel):
variables.update(**add_context)
safe_eval.check_values(variables)
- # TDE CHECKME
- # records = self.env[model].browse(it for it in res_ids if it) # filter to avoid browsing [None]
- if any(r is None for r in res_ids):
- raise ValueError(_('Unsuspected None'))
for record in self.env[model].browse(res_ids):
variables['object'] = record
try:
render_result = template.render(variables)
except Exception as e:
- _logger.info("Failed to render template : %s" % e, exc_info=True)
+ _logger.info("Failed to render template : %s", e, exc_info=True)
raise UserError(_("Failed to render template : %s", e))
if render_result == u"False":
render_result = u""
@@ -374,50 +399,63 @@ class MailRenderMixin(models.AbstractModel):
def _render_template_postprocess(self, rendered):
""" Tool method for post processing. In this method we ensure local
links ('/shop/Basil-1') are replaced by global links ('https://www.
- mygardin.com/hop/Basil-1').
+ mygarden.com/shop/Basil-1').
- :param rendered: result of ``_render_template``
+ :param rendered: result of ``_render_template``;
- :return dict: updated version of rendered
+ :return dict: updated version of rendered per record ID;
"""
- for res_id, html in rendered.items():
- rendered[res_id] = self._replace_local_links(html)
+ for res_id, rendered_html in rendered.items():
+ rendered[res_id] = self._replace_local_links(rendered_html)
return rendered
@api.model
- def _render_template(self, template_src, model, res_ids, engine='jinja', add_context=None, post_process=False):
+ def _render_template(self, template_src, model, res_ids, engine='jinja',
+ add_context=None, options=None, post_process=False):
""" Render the given string on records designed by model / res_ids using
the given rendering engine. Currently only jinja or qweb are supported.
- :param str template_src: template text to render (jinja) or xml id of view (qweb)
- this could be cleaned but hey, we are in a rush
- :param str model: model name of records on which we want to perform rendering
- :param list res_ids: list of ids of records (all belonging to same model)
- :param string engine: jinja
- :param post_process: see ``MailRenderMixin._render_field``;
+ :param str template_src: template text to render (jinja) or xml id of
+ view (qweb);
+ :param str model: model name of records on which we want to perform
+ rendering (aka 'crm.lead');
+ :param list res_ids: list of ids of records. All should belong to the
+ Odoo model given by model;
+ :param string engine: jinja or qweb_view;
+
+ :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_
Hello
', - }, - {'name': 'TestRender2', 'type': 'qweb', - 'arch': 'Hello
+ # some qweb templates, their views and their xml ids + cls.base_qweb_bits = [ + '
Hello
', + 'Hello
English Speaker Other Speaker -
""", - }, +""" + ] + 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', @@ -127,23 +127,32 @@ class TestMailRender(common.MailCommon): }) @users('employee') - def test_render_jinja(self): - source = """-% set line_statement_variable = 3 -We have ${line_statement_variable} cookies in stock -We have <% set block_variable = 4 %>${block_variable} cookies in stock -
""" + def test_evaluation_context(self): + """ Test evaluation context and various ways of tweaking it. """ partner = self.env['res.partner'].browse(self.render_object.ids) - result = self.env['mail.render.mixin']._render_template( - source, - partner._name, - partner.ids, - engine='jinja', - )[partner.id] - self.assertEqual(result, """-We have 3 cookies in stock -We have 4 cookies in stock -
""") + MailRenderMixin = self.env['mail.render.mixin'] + + custom_ctx = {'custom_ctx': 'Custom Context Value'} + add_context = { + 'custom_value': 'Custom Render Value' + } + srces = [ + 'I am ${user.name}', + 'Datetime is ${format_datetime(datetime.datetime(2021, 6, 1), dt_format="MM - d - YYY")}', + 'Context ${ctx.get("custom_ctx")}, value ${custom_value}', + ] + results = [ + 'I am %s' % self.env.user.name, + 'Datetime is 06 - 1 - 2021', + 'Context Custom Context Value, value Custom Render Value' + ] + 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): @@ -179,13 +188,131 @@ class TestMailRender(common.MailCommon): self.assertEqual(rendered, expected) @users('employee') - def test_render_template_qweb(self): + 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', + engine='qweb_view', )[partner.id].decode() 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('employee') + 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. + """ + partner = self.env['res.partner'].browse(self.render_object.ids) + + 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 = """${cust_function()}
""" + expected = """return value
""" + context = {'cust_function': cust_function} + + result = self.env['mail.render.mixin']._render_template_jinja( + src, partner._name, partner.ids, + add_context=context + )[partner.id] + self.assertEqual(expected, result) + self.assertTrue(cust_function.call) + + @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 = '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 xml + 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) + + # condition block xml + src = """Test +% if False: + Code not executed +% endif""" + expected = "Test\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 = """+% set line_statement_variable = 3 +We have ${line_statement_variable} cookies in stock +We have <% set block_variable = 4 %>${block_variable} cookies in stock +
""" + expected = """+We have 3 cookies in stock +We have 4 cookies in stock +
""" + for engine in ['jinja']: + result = MailRenderMixin._render_template( + src, partner._name, partner.ids, engine=engine, + )[partner.id] + self.assertEqual(result, expected) diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py index 568a283cf1b..4552e2d4c89 100644 --- a/addons/mass_mailing/models/mailing.py +++ b/addons/mass_mailing/models/mailing.py @@ -113,7 +113,7 @@ class MassMailing(models.Model): string='Reply To', compute='_compute_reply_to', readonly=False, store=True, help='Preferred Reply-To Address') # recipients - mailing_model_real = fields.Char(string='Recipients Real Model', compute='_compute_model') + mailing_model_real = fields.Char(string='Recipients Real Model', compute='_compute_mailing_model_real') mailing_model_id = fields.Many2one( 'ir.model', string='Recipients Model', ondelete='cascade', required=True, domain=[('is_mailing_enabled', '=', True)], @@ -239,9 +239,9 @@ class MassMailing(models.Model): mailing.medium_id = self.env.ref('utm.utm_medium_email').id @api.depends('mailing_model_id') - def _compute_model(self): - for record in self: - record.mailing_model_real = (record.mailing_model_name != 'mailing.list') and record.mailing_model_name or 'mailing.contact' + def _compute_mailing_model_real(self): + for mailing in self: + mailing.mailing_model_real = (mailing.mailing_model_name != 'mailing.list') and mailing.mailing_model_name or 'mailing.contact' @api.depends('mailing_model_real') def _compute_reply_to_mode(self): @@ -293,6 +293,12 @@ class MassMailing(models.Model): def _compute_mail_server_available(self): self.mail_server_available = self.env['ir.config_parameter'].sudo().get_param('mass_mailing.outgoing_mail_server') + # Overrides of mail.render.mixin + @api.depends('mailing_model_real') + def _compute_render_model(self): + for mailing in self: + mailing.render_model = mailing.mailing_model_real + # ------------------------------------------------------ # ORM # ------------------------------------------------------ diff --git a/addons/mass_mailing/wizard/mailing_mailing_test.py b/addons/mass_mailing/wizard/mailing_mailing_test.py index 595425638a0..933b81badbf 100644 --- a/addons/mass_mailing/wizard/mailing_mailing_test.py +++ b/addons/mass_mailing/wizard/mailing_mailing_test.py @@ -32,19 +32,22 @@ class TestMassMailing(models.TransientModel): mass_mail_layout = self.env.ref('mass_mailing.mass_mailing_mail_layout') record = self.env[mailing.mailing_model_real].search([], limit=1) - body = mailing._prepend_preview(mailing.body_html, mailing.preview) - subject = mailing.subject # If there is atleast 1 record for the model used in this mailing, then we use this one to render the template # Downside: Jinja 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 jinja - body = self.env['mail.render.mixin']._render_template(body, mailing.mailing_model_real, record.ids, post_process=True)[record.id] - subject = self.env['mail.render.mixin']._render_template(subject, mailing.mailing_model_real, record.ids)[record.id] + 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] + full_body = mailing._prepend_preview(body, preview) + subject = mailing._render_field('subject', record.ids)[record.id] + else: + full_body = mailing._prepend_preview(mailing.body_html, mailing.preview) + subject = mailing.subject # Convert links in absolute URLs before the application of the shortener - body = self.env['mail.render.mixin']._replace_local_links(body) - body = tools.html_sanitize(body, sanitize_attributes=True, sanitize_style=True) + full_body = self.env['mail.render.mixin']._replace_local_links(full_body) + full_body = tools.html_sanitize(full_body, sanitize_attributes=True, sanitize_style=True) for valid_email in valid_emails: mail_values = { @@ -52,7 +55,7 @@ class TestMassMailing(models.TransientModel): 'reply_to': mailing.reply_to, 'email_to': valid_email, 'subject': subject, - 'body_html': mass_mail_layout._render({'body': body}, engine='ir.qweb', minimal_qcontext=True), + 'body_html': mass_mail_layout._render({'body': full_body}, engine='ir.qweb', minimal_qcontext=True), 'notification': True, 'mailing_id': mailing.id, 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], diff --git a/addons/sms/models/sms_template.py b/addons/sms/models/sms_template.py index be402f02a37..6991fecf741 100644 --- a/addons/sms/models/sms_template.py +++ b/addons/sms/models/sms_template.py @@ -29,6 +29,16 @@ class SMSTemplate(models.Model): help="Sidebar action to make this template available on records " "of the related document model") + # Overrides of mail.render.mixin + @api.depends('model') + def _compute_render_model(self): + for template in self: + template.render_model = template.model + + # ------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------ + @api.returns('self', lambda value: value.id) def copy(self, default=None): default = dict(default or {}, diff --git a/addons/test_mail_full/tests/test_sms_template.py b/addons/test_mail_full/tests/test_sms_template.py index abc22fcf269..e8382546a5e 100644 --- a/addons/test_mail_full/tests/test_sms_template.py +++ b/addons/test_mail_full/tests/test_sms_template.py @@ -23,6 +23,9 @@ class TestSmsTemplate(TestMailFullCommon, TestMailFullRecipients): rendered_body = self.sms_template._render_template(self.sms_template.body, self.sms_template.model, self.test_record.ids) self.assertEqual(rendered_body[self.test_record.id], 'Dear %s this is an SMS.' % self.test_record.display_name) + rendered_body = self.sms_template._render_field('body', self.test_record.ids) + self.assertEqual(rendered_body[self.test_record.id], 'Dear %s this is an SMS.' % self.test_record.display_name) + def test_sms_template_lang(self): self.env['res.lang']._activate_lang('fr_FR') self.user_admin.write({'lang': 'en_US'})