From c795927ce1e1c26b30ff1332def2318377828ccc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?St=C3=A9phane=20Debauche?=
Date: Mon, 17 May 2021 13:54:50 +0000
Subject: [PATCH 1/9] [IMP] auth_signup: do not write on the sign_up template
Remove arbitrary write on template used in signup process. Instead of writing
on template if some values are not correct compared to the current flow
we force values given to ``send_mail``. It allows to let people tune the
template while still having a working auth signup process.
Task ID-2534550 (Template usage improvement)
Task ID-2477164 (Composer mixin)
COM PR odoo/odoo#70889
ENT PR odoo/enterprise#18352
---
addons/auth_signup/models/res_users.py | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/addons/auth_signup/models/res_users.py b/addons/auth_signup/models/res_users.py
index 50ae72c0f85..78c3411b2ea 100644
--- a/addons/auth_signup/models/res_users.py
+++ b/addons/auth_signup/models/res_users.py
@@ -187,15 +187,14 @@ class ResUsers(models.Model):
template = self.env.ref('auth_signup.reset_password_email')
assert template._name == 'mail.template'
- template_values = {
+ email_values = {
'email_to': '${object.email|safe}',
'email_cc': False,
'auto_delete': True,
- 'partner_to': False,
+ 'recipient_ids': [],
+ 'partner_ids': [],
'scheduled_date': False,
}
- if any(template[field] != value for (field, value) in template_values.items()):
- template.write(template_values)
for user in self:
if not user.email:
@@ -203,7 +202,7 @@ class ResUsers(models.Model):
# TDE FIXME: make this template technical (qweb)
with self.env.cr.savepoint():
force_send = not(self.env.context.get('import_file', False))
- template.send_mail(user.id, force_send=force_send, raise_exception=True)
+ template.send_mail(user.id, force_send=force_send, raise_exception=True, email_values=email_values)
_logger.info("Password reset email sent for user <%s> to <%s>", user.login, user.email)
def send_unregistered_user_reminder(self, after_days=5):
From 2d6df1fe7a9eb7b63ff04229f32ac3020fb6a214 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?St=C3=A9phane=20Debauche?=
Date: Thu, 27 May 2021 09:26:34 +0000
Subject: [PATCH 2/9] [IMP] mail, sms, mass_mailing: define rendering model at
render level and improve mixin code
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Currently ``mail.render.mixin`` offers rendering tools, some of them being
based on a ``model`` field. It allows to know which model to use to fetch
records on which we perform rendering. However this field is not defined at
mixin level but in inheriting models without being clearly implemented that
way (see ``mail.template`` or ``sms.template`` models).
In order to clean this mixin it is now defined at mixin level, using a
not stored computed field allowing to define how to find this model. Sub
models are updated accordingly.
Other cleaning is done in the render mixin
* rename ``_render_template_qweb`` to ``_render_template_qweb_view``
to indicate it works on views, not on raw qweb templates;
* extract some common available variables for rendering in a method then
called / upated for jinja and qweb views;
* correctly set same rendering context for jinja and qweb views rendering;
* allow to propagate an additional context from _render_field to sub
rendering methods;
* allow to propagate options through rendering methods (notably for escaping
or safe attributes in jinja);
* allow to specify engine used to render lang;
* clean or update some docstrings;
Some tests are also added, as render mixin lacks some more detailed test to
ensure various use cases will be correctly migrated to other engines like
QWeb.
Task ID-2534550 (Template usage improvement)
Task ID-2477164 (Composer mixin)
Prepares Task ID-27033 (QWeb in templates)
COM PR odoo/odoo#70889
ENT PR odoo/enterprise#18352
Co-Authored-By: Stéphane Debauche
Co-Authored-By: Thibault Delavallée
---
addons/digest/models/digest.py | 2 +-
addons/mail/models/mail_render_mixin.py | 237 +++++++++++-------
addons/mail/models/mail_template.py | 12 +-
addons/mail/tests/test_mail_render.py | 187 +++++++++++---
addons/mass_mailing/models/mailing.py | 14 +-
.../wizard/mailing_mailing_test.py | 17 +-
addons/sms/models/sms_template.py | 10 +
.../test_mail_full/tests/test_sms_template.py | 3 +
8 files changed, 343 insertions(+), 139 deletions(-)
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__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``;
:return dict: {res_id: string of rendered template based on record}
"""
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 ('jinja', 'qweb'):
+ if engine not in ('jinja', 'qweb_view'):
raise ValueError(_('Template rendering supports only jinja or qweb.'))
- if engine == 'qweb':
- rendered = self._render_template_qweb(template_src, model, res_ids, add_context=add_context)
+ if engine == 'qweb_view':
+ rendered = self._render_template_qweb_view(template_src, model, res_ids,
+ add_context=add_context, options=options)
else:
- rendered = self._render_template_jinja(template_src, model, res_ids, add_context=add_context)
+ rendered = self._render_template_jinja(template_src, model, res_ids,
+ add_context=add_context, options=options)
if post_process:
rendered = self._render_template_postprocess(rendered)
return rendered
- def _render_lang(self, res_ids):
+ def _render_lang(self, res_ids, engine='jinja'):
""" Given some record ids, return the lang for each record based on
- lang field of template or through specific context-based key.
+ lang field of template or through specific context-based key. Lang is
+ computed by performing a rendering on res_ids, based on self.render_model.
- :param list res_ids: list of ids of records (all belonging to same model
- defined by self.model)
+ :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;
:return dict: {res_id: lang code (i.e. en_US)}
"""
@@ -425,27 +463,30 @@ class MailRenderMixin(models.AbstractModel):
if not isinstance(res_ids, (list, tuple)):
raise ValueError(_('Template rendering for language should be called with a list of IDs.'))
- if self.env.context.get('template_preview_lang'):
- return dict((res_id, self.env.context['template_preview_lang']) for res_id in res_ids)
- else:
- rendered_langs = self._render_template(self.lang, self.model, res_ids)
- return dict((res_id, lang)
- for res_id, lang in rendered_langs.items())
+ rendered_langs = self._render_template(self.lang, self.render_model, res_ids, engine=engine)
+ return dict(
+ (res_id, lang)
+ for res_id, lang in rendered_langs.items()
+ )
- def _classify_per_lang(self, res_ids):
+ def _classify_per_lang(self, res_ids, engine='jinja'):
""" Given some record ids, return for computed each lang a contextualized
template and its subset of res_ids.
:param list res_ids: list of ids of records (all belonging to same model
- defined by self.model)
+ defined by self.render_model)
+ :param string engine: jinja or qweb_view;
:return dict: {lang: (template with lang=lang_code if specific lang computed
or template, res_ids targeted by that language}
"""
self.ensure_one()
+ if self.env.context.get('template_preview_lang'):
+ return dict((res_id, self.env.context['template_preview_lang']) for res_id in res_ids)
+
lang_to_res_ids = {}
- for res_id, lang in self._render_lang(res_ids).items():
+ for res_id, lang in self._render_lang(res_ids, engine=engine).items():
lang_to_res_ids.setdefault(lang, []).append(res_id)
return dict(
@@ -453,16 +494,18 @@ class MailRenderMixin(models.AbstractModel):
for lang, lang_res_ids in lang_to_res_ids.items()
)
- def _render_field(self, field, res_ids,
+ def _render_field(self, field, res_ids, engine='jinja',
compute_lang=False, set_lang=False,
- post_process=False):
+ add_context=None, options=None, post_process=False):
""" 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
on self.
+ :param field: a field name existing on self;
:param list res_ids: list of ids of records (all belonging to same model
- defined by ``self.model``)
+ defined by ``self.render_model``)
+ :param string engine: jinja or qweb_view;
:param boolean compute_lang: compute language to render on translated
version of the template instead of default (probably english) one.
@@ -470,6 +513,8 @@ class MailRenderMixin(models.AbstractModel):
:param string set_lang: force language for rendering. It should be a
valid lang code matching an activate res.lang. Checked only if
``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``);
@@ -487,7 +532,7 @@ class MailRenderMixin(models.AbstractModel):
(res_id, rendered)
for lang, (template, tpl_res_ids) in templates_res_ids.items()
for res_id, rendered in template._render_template(
- template[field], template.model, tpl_res_ids,
- post_process=post_process
+ template[field], template.render_model, tpl_res_ids, 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 479af463d16..9f79d717a7c 100644
--- a/addons/mail/models/mail_template.py
+++ b/addons/mail/models/mail_template.py
@@ -67,6 +67,16 @@ class MailTemplate(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
+ # ------------------------------------------------------------
+
def unlink(self):
self.unlink_action()
return super(MailTemplate, self).unlink()
@@ -163,9 +173,9 @@ class MailTemplate(models.Model):
results = dict()
for lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items():
for field in fields:
- template = template.with_context(safe=(field == 'subject'))
generated_field_values = template._render_field(
field, template_res_ids,
+ options={'render_safe': field == 'subject'},
post_process=(field == 'body_html')
)
for res_id, field_value in generated_field_values.items():
diff --git a/addons/mail/tests/test_mail_render.py b/addons/mail/tests/test_mail_render.py
index 2f14cbca97e..7d1738c11bc 100644
--- a/addons/mail/tests/test_mail_render.py
+++ b/addons/mail/tests/test_mail_render.py
@@ -52,20 +52,20 @@ class TestMailRender(common.MailCommon):
"""
]
- # some qweb templates and their xml ids
- cls.base_qweb_templates = cls.env['ir.ui.view'].create([
- {'name': 'TestRender', 'type': 'qweb',
- 'arch': '
-% 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 = """
This is a test
+
${cust_function()}
"""
+ expected = """
This is a test
+
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'})
From b19fe6dea552fa8300af2d3a4a51560ec588447d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?St=C3=A9phane=20Debauche?=
Date: Mon, 17 May 2021 14:23:45 +0000
Subject: [PATCH 3/9] [IMP] mail: introduce a composer mixin for invite / send
email wizards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This commits introduces a new mixin ``mail.composer.mixin`` used when sending
emails or notifications based on a mail template.
Main current purpose is to hide details related to subject and body computation
and rendering based on a mail.template. It also give the base tools to control
who is allowed to edit body, notably when dealing with templating language
like jinja or qweb.
It is meant to evolve in a near future with upcoming support of qweb and fine
grain control of rendering access.
Task ID-2534550 (Template usage improvement)
Task ID-2477164 (Composer mixin)
COM PR odoo/odoo#70889
ENT PR odoo/enterprise#18352
Co-Authored-By: Stéphane Debauche
Co-Authored-By: Thibault Delavallée
---
addons/mail/models/__init__.py | 4 +-
addons/mail/models/mail_composer_mixin.py | 49 +++++++++++++++++++
addons/test_mail/models/test_mail_models.py | 13 +++++
addons/test_mail/security/ir.model.access.csv | 2 +
addons/test_mail/tests/__init__.py | 1 +
.../tests/test_mail_composer_mixin.py | 49 +++++++++++++++++++
6 files changed, 117 insertions(+), 1 deletion(-)
create mode 100644 addons/mail/models/mail_composer_mixin.py
create mode 100644 addons/test_mail/tests/test_mail_composer_mixin.py
diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py
index 03c7e597fa2..97eaa360340 100644
--- a/addons/mail/models/__init__.py
+++ b/addons/mail/models/__init__.py
@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import mail_render_mixin
+from . import mail_composer_mixin
+
from . import mail_message_subtype
from . import mail_tracking_value
from . import mail_alias
from . import mail_alias_mixin
from . import mail_followers
from . import mail_notification
-from . import mail_render_mixin
from . import mail_message
from . import mail_activity
from . import mail_mail
diff --git a/addons/mail/models/mail_composer_mixin.py b/addons/mail/models/mail_composer_mixin.py
new file mode 100644
index 00000000000..03d4fcc6a87
--- /dev/null
+++ b/addons/mail/models/mail_composer_mixin.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class MailComposerMixin(models.AbstractModel):
+ """ Mixin used to edit and render some fields used when sending emails or
+ notifications based on a mail template.
+
+ Main current purpose is to hide details related to subject and body computation
+ and rendering based on a mail.template. It also give the base tools to control
+ who is allowed to edit body, notably when dealing with templating language
+ like jinja or qweb.
+
+ It is meant to evolve in a near future with upcoming support of qweb and fine
+ grain control of rendering access.
+ """
+ _name = 'mail.composer.mixin'
+ _inherit = 'mail.render.mixin'
+ _description = 'Mail Composer Mixin'
+
+ # Content
+ subject = fields.Char('Subject', compute='_compute_subject', readonly=False, store=True)
+ body = fields.Html('Contents', sanitize_style=True, compute='_compute_body', store=True, readonly=False)
+ template_id = fields.Many2one('mail.template', 'Mail Template', domain="[('model', '=', render_model)]")
+ # Access
+ can_edit_body = fields.Boolean('Can Edit Body', compute='_compute_can_edit_body')
+
+ @api.depends('template_id')
+ def _compute_subject(self):
+ for composer_mixin in self:
+ if composer_mixin.template_id:
+ composer_mixin.subject = composer_mixin.template_id.subject
+ elif not composer_mixin.subject:
+ composer_mixin.subject = False
+
+ @api.depends('template_id')
+ def _compute_body(self):
+ for composer_mixin in self:
+ if composer_mixin.template_id:
+ composer_mixin.body = composer_mixin.template_id.body_html
+ elif not composer_mixin.body:
+ composer_mixin.body = False
+
+ @api.depends('template_id')
+ @api.depends_context('uid')
+ def _compute_can_edit_body(self):
+ self.can_edit_body = True
diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py
index 47fdf6ae1ed..b0773d71c95 100644
--- a/addons/test_mail/models/test_mail_models.py
+++ b/addons/test_mail/models/test_mail_models.py
@@ -122,3 +122,16 @@ class MailTestContainer(models.Model):
values['alias_force_thread_id'] = self.id
values['alias_parent_thread_id'] = self.id
return values
+
+class MailTestComposerMixin(models.Model):
+ """ A simple invite-like wizard using the composer mixin, rendering on
+ itself. """
+ _description = 'Invite-like Wizard'
+ _name = 'mail.test.composer.mixin'
+ _inherit = ['mail.composer.mixin']
+
+ name = fields.Char('Name')
+ author_id = fields.Many2one('res.partner')
+
+ def _compute_render_model(self):
+ self.render_model = self._name
diff --git a/addons/test_mail/security/ir.model.access.csv b/addons/test_mail/security/ir.model.access.csv
index 2f2fe28732d..5a834b20a8d 100644
--- a/addons/test_mail/security/ir.model.access.csv
+++ b/addons/test_mail/security/ir.model.access.csv
@@ -11,6 +11,8 @@ access_mail_test_activity_portal,mail.test.activity.portal,model_mail_test_activ
access_mail_test_activity_user,mail.test.activity.user,model_mail_test_activity,base.group_user,1,1,1,1
access_mail_test_ticket_portal,mail.test.ticket.portal,model_mail_test_ticket,base.group_portal,0,0,0,0
access_mail_test_ticket_user,mail.test.ticket.user,model_mail_test_ticket,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_container_portal,mail.test.container_portal,model_mail_test_container,base.group_portal,1,1,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_cc_portal,mail.test.cc.portal,model_mail_test_cc,base.group_portal,1,0,0,0
diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py
index 15d441d888c..547eb93779a 100644
--- a/addons/test_mail/tests/__init__.py
+++ b/addons/test_mail/tests/__init__.py
@@ -4,6 +4,7 @@ from . import test_invite
from . import test_ir_actions
from . import test_mail_activity
from . import test_mail_composer
+from . import test_mail_composer_mixin
from . import test_mail_followers
from . import test_mail_message
from . import test_mail_mail
diff --git a/addons/test_mail/tests/test_mail_composer_mixin.py b/addons/test_mail/tests/test_mail_composer_mixin.py
new file mode 100644
index 00000000000..265a509da9d
--- /dev/null
+++ b/addons/test_mail/tests/test_mail_composer_mixin.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients
+from odoo.tests import tagged
+from odoo.tests.common import users
+
+
+@tagged('mail_composer_mixin')
+class TestMailComposerMixin(TestMailCommon, TestRecipients):
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestMailComposerMixin, cls).setUpClass()
+ cls._init_mail_gateway()
+
+ cls.mail_template = cls.env['mail.template'].create({
+ 'subject': 'Subject for ${object.name}',
+ 'body_html': '