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

Hello

', - }, - {'name': 'TestRender2', 'type': 'qweb', - 'arch': '

Hello

', - }, - {'name': 'TestRender3', 'type': 'qweb', - 'arch': """

+ # 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 = """

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

Body for ${object.name}

', + }) + + @users("employee") + def test_content_sync(self): + record = self.env['mail.test.composer.mixin'].create({ + 'name': 'Invite', + 'template_id': self.mail_template.id, + }) + self.assertEqual(record.subject, self.mail_template.subject) + self.assertEqual(record.body, self.mail_template.body_html) + + subject = record._render_field('subject', record.ids)[record.id] + self.assertEqual(subject, 'Subject for %s' % record.name) + body = record._render_field('body', record.ids)[record.id] + self.assertEqual(body, '

Body for %s

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

Content from ${user.name}

', + }) + self.assertEqual(record.subject, 'Subject for ${object.name}') + self.assertEqual(record.body, '

Content from ${user.name}

') + + subject = record._render_field('subject', record.ids)[record.id] + self.assertEqual(subject, 'Subject for %s' % record.name) + body = record._render_field('body', record.ids)[record.id] + self.assertEqual(body, '

Content from %s

' % self.env.user.name) From 088d5e8dd3439752a32e06f77ae38cfee6dfb98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Debauche?= Date: Mon, 17 May 2021 14:29:54 +0000 Subject: [PATCH 4/9] [IMP] survey: use new mail.composer.mixin Using this mixin allows to hide details about jinja-based computation for main fields used when posting messages or sending emails. Notably subject and body computation that was duplicated in several addons are now done in a mixin. It also eases their maintenance or evolution when qweb rendering will be available. It also eases support of lang when necessary. Task ID-2534550 (Template usage improvement) Task ID-2477164 (Composer mixin) COM PR odoo/odoo#70889 ENT PR odoo/enterprise#18352 UPG PR odoo/upgrade#2496 --- addons/survey/__manifest__.py | 2 +- addons/survey/wizard/survey_invite.py | 31 +++++--------------- addons/survey/wizard/survey_invite_views.xml | 1 + 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/addons/survey/__manifest__.py b/addons/survey/__manifest__.py index 12ec1e16e53..fdc1e41dfc5 100644 --- a/addons/survey/__manifest__.py +++ b/addons/survey/__manifest__.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'Surveys', - 'version': '3.3', + 'version': '3.4', 'category': 'Marketing/Surveys', 'description': """ Create beautiful surveys and visualize answers diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index 48cfabb8f26..c2366c997d3 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -15,6 +15,7 @@ emails_split = re.compile(r"[;,\n\r]+") class SurveyInvite(models.TransientModel): _name = 'survey.invite' + _inherit = 'mail.composer.mixin' _description = 'Survey Invitation Wizard' @api.model @@ -28,14 +29,9 @@ class SurveyInvite(models.TransientModel): return self.env.user.partner_id # composer content - subject = fields.Char('Subject', compute='_compute_subject', readonly=False, store=True) - body = fields.Html('Contents', sanitize_style=True, compute='_compute_body', readonly=False, store=True) attachment_ids = fields.Many2many( 'ir.attachment', 'survey_mail_compose_message_ir_attachments_rel', 'wizard_id', 'attachment_id', string='Attachments') - template_id = fields.Many2one( - 'mail.template', 'Use template', index=True, - domain="[('model', '=', 'survey.user_input')]") # origin email_from = fields.Char('From', default=_get_default_from, help="Email address of the sender.") author_id = fields.Many2one( @@ -106,6 +102,11 @@ class SurveyInvite(models.TransientModel): for invite in self: invite.survey_start_url = werkzeug.urls.url_join(base_url, invite.survey_id.get_start_url()) if invite.survey_id else False + # Overrides of mail.composer.mixin + @api.depends('survey_id') # fake trigger otherwise not computed in new mode + def _compute_render_model(self): + self.render_model = 'survey.user_input' + @api.onchange('emails') def _onchange_emails(self): if self.emails and (self.survey_users_login_required and not self.survey_id.users_can_signup): @@ -138,22 +139,6 @@ class SurveyInvite(models.TransientModel): ', '.join(invalid_partners.mapped('name')) )) - @api.depends('template_id') - def _compute_subject(self): - for invite in self: - if invite.template_id: - invite.subject = invite.template_id.subject - elif not invite.subject: - invite.subject = False - - @api.depends('template_id') - def _compute_body(self): - for invite in self: - if invite.template_id: - invite.body = invite.template_id.body_html - elif not invite.body: - invite.body = False - @api.model_create_multi def create(self, vals_list): for values in vals_list: @@ -210,8 +195,8 @@ class SurveyInvite(models.TransientModel): def _send_mail(self, answer): """ Create mail specific for recipient containing notably its access token """ - subject = self.env['mail.render.mixin'].with_context(safe=True)._render_template(self.subject, 'survey.user_input', answer.ids, post_process=True)[answer.id] - body = self.env['mail.render.mixin']._render_template(self.body, 'survey.user_input', answer.ids, post_process=True)[answer.id] + subject = self._render_field('subject', answer.ids, post_process=False)[answer.id] + body = self._render_field('body', answer.ids, post_process=True)[answer.id] # post the message mail_values = { 'email_from': self.email_from, diff --git a/addons/survey/wizard/survey_invite_views.xml b/addons/survey/wizard/survey_invite_views.xml index 1c2d66cc59c..bcd1e0b4c0d 100644 --- a/addons/survey/wizard/survey_invite_views.xml +++ b/addons/survey/wizard/survey_invite_views.xml @@ -13,6 +13,7 @@ + From 300a79c738d4f5be73091b6b179356b4b9cf1a62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Debauche?= Date: Mon, 17 May 2021 14:28:20 +0000 Subject: [PATCH 5/9] [IMP] website_slides: use new mail.composer.mixin Using this mixin allows to hide details about jinja-based computation for main fields used when posting messages or sending emails. Notably subject and body computation that was duplicated in several addons are now done in a mixin. It also eases their maintenance or evolution when qweb rendering will be available. It also eases support of lang when necessary. Task ID-2534550 (Template usage improvement) Task ID-2477164 (Composer mixin) COM PR odoo/odoo#70889 ENT PR odoo/enterprise#18352 UPG PR odoo/upgrade#2496 --- addons/website_slides/__manifest__.py | 2 +- .../wizard/slide_channel_invite.py | 30 +++++-------------- .../wizard/slide_channel_invite_views.xml | 1 + 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/addons/website_slides/__manifest__.py b/addons/website_slides/__manifest__.py index a732d7d92e9..ddb71e0a9f2 100644 --- a/addons/website_slides/__manifest__.py +++ b/addons/website_slides/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'eLearning', - 'version': '2.3', + 'version': '2.4', 'sequence': 125, 'summary': 'Manage and publish an eLearning platform', 'website': 'https://www.odoo.com/page/slides', diff --git a/addons/website_slides/wizard/slide_channel_invite.py b/addons/website_slides/wizard/slide_channel_invite.py index 737651e6a35..c41459a0adb 100644 --- a/addons/website_slides/wizard/slide_channel_invite.py +++ b/addons/website_slides/wizard/slide_channel_invite.py @@ -6,7 +6,6 @@ import re from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.tools import formataddr _logger = logging.getLogger(__name__) @@ -15,35 +14,20 @@ emails_split = re.compile(r"[;,\n\r]+") class SlideChannelInvite(models.TransientModel): _name = 'slide.channel.invite' + _inherit = 'mail.composer.mixin' _description = 'Channel Invitation Wizard' # composer content - subject = fields.Char('Subject', compute='_compute_subject', readonly=False, store=True) - body = fields.Html('Contents', sanitize_style=True, compute='_compute_body', readonly=False, store=True) attachment_ids = fields.Many2many('ir.attachment', string='Attachments') - template_id = fields.Many2one( - 'mail.template', 'Use template', - domain="[('model', '=', 'slide.channel.partner')]") # recipients partner_ids = fields.Many2many('res.partner', string='Recipients') # slide channel channel_id = fields.Many2one('slide.channel', string='Slide channel', required=True) - @api.depends('template_id') - def _compute_subject(self): - for invite in self: - if invite.template_id: - invite.subject = invite.template_id.subject - elif not invite.subject: - invite.subject = False - - @api.depends('template_id') - def _compute_body(self): - for invite in self: - if invite.template_id: - invite.body = invite.template_id.body_html - elif not invite.body: - invite.body = False + # Overrides of mail.composer.mixin + @api.depends('channel_id') # fake trigger otherwise not computed in new mode + def _compute_render_model(self): + self.render_model = 'slide.channel.partner' @api.onchange('partner_ids') def _onchange_partner_ids(self): @@ -92,8 +76,8 @@ class SlideChannelInvite(models.TransientModel): def _prepare_mail_values(self, slide_channel_partner): """ Create mail specific for recipient """ - subject = self.env['mail.render.mixin']._render_template(self.subject, 'slide.channel.partner', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] - body = self.env['mail.render.mixin']._render_template(self.body, 'slide.channel.partner', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] + subject = self._render_field('subject', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] + body = self._render_field('body', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] # post the message mail_values = { 'email_from': self.env.user.email_formatted, diff --git a/addons/website_slides/wizard/slide_channel_invite_views.xml b/addons/website_slides/wizard/slide_channel_invite_views.xml index d5c8e34bba6..72fbeeab9d0 100644 --- a/addons/website_slides/wizard/slide_channel_invite_views.xml +++ b/addons/website_slides/wizard/slide_channel_invite_views.xml @@ -14,6 +14,7 @@ context="{'force_email':True, 'show_email':True, 'no_create_edit': True}"/> + From bfbd42c5f4b21a02b7d938030119f8f67c7a761a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 28 May 2021 12:42:45 +0000 Subject: [PATCH 6/9] [FIX] survey, website_slides: make invite coherent and improve templates In this commit we fix some glitches in slide invite process * prevent from sending without recipients as it makes no sense; * hide Invite link in kanban like Invite button on form view, when not being invite-based; * fix small issues in various templates, notably escaping in subject; Task ID-2534550 (Template usage improvement) Task ID-2477164 (Composer mixin) COM PR odoo/odoo#70889 --- addons/survey/data/mail_template_data.xml | 2 +- addons/survey/wizard/survey_invite.py | 2 +- addons/website_sale_slides/views/slide_channel_views.xml | 6 ------ addons/website_slides/views/slide_channel_views.xml | 6 ++++-- addons/website_slides/wizard/slide_channel_invite.py | 8 ++++---- .../website_slides/wizard/slide_channel_invite_views.xml | 3 ++- 6 files changed, 12 insertions(+), 15 deletions(-) diff --git a/addons/survey/data/mail_template_data.xml b/addons/survey/data/mail_template_data.xml index 1e138281ec9..413c455c40c 100644 --- a/addons/survey/data/mail_template_data.xml +++ b/addons/survey/data/mail_template_data.xml @@ -4,7 +4,7 @@ Survey: Invite - Participate to ${object.survey_id.title} survey + Participate to ${object.survey_id.display_name} survey ${(object.partner_id.email_formatted or object.email) |safe}
diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index c2366c997d3..81a567d7066 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -195,7 +195,7 @@ class SurveyInvite(models.TransientModel): def _send_mail(self, answer): """ Create mail specific for recipient containing notably its access token """ - subject = self._render_field('subject', answer.ids, post_process=False)[answer.id] + subject = self._render_field('subject', answer.ids, options={'render_safe': True})[answer.id] body = self._render_field('body', answer.ids, post_process=True)[answer.id] # post the message mail_values = { diff --git a/addons/website_sale_slides/views/slide_channel_views.xml b/addons/website_sale_slides/views/slide_channel_views.xml index 88a3f4ff431..cffeeabbed6 100644 --- a/addons/website_sale_slides/views/slide_channel_views.xml +++ b/addons/website_sale_slides/views/slide_channel_views.xml @@ -28,9 +28,6 @@ slide.channel - - -
@@ -38,9 +35,6 @@
- - {'invisible': [('enroll', '=', 'payment')]} -
diff --git a/addons/website_slides/views/slide_channel_views.xml b/addons/website_slides/views/slide_channel_views.xml index 56ecda05ad1..94e6c0dc6e7 100644 --- a/addons/website_slides/views/slide_channel_views.xml +++ b/addons/website_slides/views/slide_channel_views.xml @@ -8,7 +8,7 @@
-
@@ -207,6 +207,7 @@ + @@ -231,7 +232,8 @@ -
+
diff --git a/addons/website_slides/wizard/slide_channel_invite.py b/addons/website_slides/wizard/slide_channel_invite.py index c41459a0adb..79698286ade 100644 --- a/addons/website_slides/wizard/slide_channel_invite.py +++ b/addons/website_slides/wizard/slide_channel_invite.py @@ -61,6 +61,8 @@ class SlideChannelInvite(models.TransientModel): if not self.env.user.email: raise UserError(_("Unable to post message, please configure the sender's email address.")) + if not self.partner_ids: + raise UserError(_("Please select at least one recipient.")) mail_values = [] for partner_id in self.partner_ids: @@ -68,15 +70,13 @@ class SlideChannelInvite(models.TransientModel): if slide_channel_partner: mail_values.append(self._prepare_mail_values(slide_channel_partner)) - # TODO awa: change me to create multi when mail.mail supports it - for mail_value in mail_values: - self.env['mail.mail'].sudo().create(mail_value) + self.env['mail.mail'].sudo().create(mail_values) return {'type': 'ir.actions.act_window_close'} def _prepare_mail_values(self, slide_channel_partner): """ Create mail specific for recipient """ - subject = self._render_field('subject', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] + subject = self._render_field('subject', slide_channel_partner.ids, options={'render_safe': True})[slide_channel_partner.id] body = self._render_field('body', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] # post the message mail_values = { diff --git a/addons/website_slides/wizard/slide_channel_invite_views.xml b/addons/website_slides/wizard/slide_channel_invite_views.xml index 72fbeeab9d0..543369289c8 100644 --- a/addons/website_slides/wizard/slide_channel_invite_views.xml +++ b/addons/website_slides/wizard/slide_channel_invite_views.xml @@ -11,7 +11,8 @@ + required="1" + context="{'force_email':True, 'show_email':True, 'no_create_edit': True, 'no_quick_create': True}"/> From 648708623a43580ad7a5e3a20d0ea8f73b8e137b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Debauche?= Date: Mon, 17 May 2021 16:04:38 +0000 Subject: [PATCH 7/9] [IMP] mail: make mail composer use the new mail.composer.mixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using this mixin allows to hide details about jinja-based computation for main fields used when posting messages or sending emails. Notably subject and body computation that was duplicated in several addons are now done in a mixin. It also eases their maintenance or evolution when qweb rendering will be available. It also eases support of lang when necessary. ``mail.compose.message`` is still overriding default mixin behavior to keep a minimal fix. Rewriting composer code is planned but will not be done with this task. Here we keep current behavior, technically and functionally. Task ID-2534550 (Template usage improvement) Task ID-2477164 (Composer mixin) COM PR odoo#70889 ENT PR odoo/enterprise#18352 UPG PR odoo/upgrade#2496 Co-Authored-By: Stéphane Debauche Co-Authored-By: Thibault Delavallée --- addons/mail/wizard/mail_compose_message.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index e1d790afcab..b55645c5755 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -37,6 +37,7 @@ class MailComposer(models.TransientModel): before being sent to each recipient. """ _name = 'mail.compose.message' + _inherit = 'mail.composer.mixin' _description = 'Email composition wizard' _log_access = True _batch_size = 500 @@ -81,8 +82,8 @@ class MailComposer(models.TransientModel): return filtered_result # content - subject = fields.Char('Subject') - body = fields.Html('Contents', default='', sanitize_style=True) + subject = fields.Char('Subject', compute=False) + body = fields.Html('Contents', compute=False, default='', sanitize_style=True) parent_id = fields.Many2one( 'mail.message', 'Parent Message', index=True, ondelete='set null', help="Initial thread message.") @@ -154,6 +155,12 @@ class MailComposer(models.TransientModel): for composer in self: composer.reply_to_force_new = composer.reply_to_mode == 'new' + # Overrides of mail.render.mixin + @api.depends('model') + def _compute_render_model(self): + for composer in self: + composer.render_model = composer.model + @api.model def get_record_data(self, values): """ Returns a defaults-like dict with initial values for the composition @@ -469,10 +476,10 @@ class MailComposer(models.TransientModel): multi_mode = False res_ids = [res_ids] - subjects = self.env['mail.render.mixin']._render_template(self.subject, self.model, res_ids) - bodies = self.env['mail.render.mixin']._render_template(self.body, self.model, res_ids, post_process=True) - emails_from = self.env['mail.render.mixin']._render_template(self.email_from, self.model, res_ids) - replies_to = self.env['mail.render.mixin']._render_template(self.reply_to, self.model, res_ids) + subjects = self._render_field('subject', res_ids, options={"render_safe": True}) + bodies = self._render_field('body', res_ids, post_process=True) + emails_from = self._render_field('email_from', res_ids) + replies_to = self._render_field('reply_to', res_ids) default_recipients = {} if not self.partner_ids: records = self.env[self.model].browse(res_ids).sudo() From 1766e0147d8d59944c9d37dd20095d3a0a1959f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 27 May 2021 13:32:15 +0000 Subject: [PATCH 8/9] [MOV] various: reorganize templates into their right files Purpose is to have all mail template into a mail_template_data.xml file when possible. It eases maintenance and update when having to work globally on template records. Also update some ``body_html`` declarations still using ``xml`` instead of ``html``. 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 --- addons/account/data/mail_template_data.xml | 2 +- .../gamification/data/mail_template_data.xml | 2 +- addons/gift_card/__manifest__.py | 2 +- ...plate_email.xml => mail_template_data.xml} | 1 + addons/repair/data/mail_template_data.xml | 2 +- addons/survey/data/mail_template_data.xml | 50 +++++++++---------- addons/test_mail/data/template_data.xml | 4 +- .../data/mail_template_data.xml | 2 +- .../website_sale/data/mail_template_data.xml | 2 +- 9 files changed, 34 insertions(+), 33 deletions(-) rename addons/gift_card/data/{gift_card_template_email.xml => mail_template_data.xml} (98%) diff --git a/addons/account/data/mail_template_data.xml b/addons/account/data/mail_template_data.xml index b572cb19c4e..d1664372d6e 100644 --- a/addons/account/data/mail_template_data.xml +++ b/addons/account/data/mail_template_data.xml @@ -61,7 +61,7 @@ ${object.company_id.name} Payment Receipt (Ref ${object.name or 'n/a' }) ${object.partner_id.id} - +

Dear ${object.partner_id.name}

diff --git a/addons/gamification/data/mail_template_data.xml b/addons/gamification/data/mail_template_data.xml index 0c48fc7bc14..380e18fd470 100644 --- a/addons/gamification/data/mail_template_data.xml +++ b/addons/gamification/data/mail_template_data.xml @@ -6,7 +6,7 @@ New badge ${object.badge_id.name} granted ${object.user_id.partner_id.id} - +
diff --git a/addons/gift_card/__manifest__.py b/addons/gift_card/__manifest__.py index 58e24abdec1..f4eac0cb69e 100644 --- a/addons/gift_card/__manifest__.py +++ b/addons/gift_card/__manifest__.py @@ -9,7 +9,7 @@ 'version': '1.0', 'depends': ['sale'], 'data': [ - 'data/gift_card_template_email.xml', + 'data/mail_template_data.xml', 'data/gift_card_data.xml', 'views/views.xml', 'views/templates.xml', diff --git a/addons/gift_card/data/gift_card_template_email.xml b/addons/gift_card/data/mail_template_data.xml similarity index 98% rename from addons/gift_card/data/gift_card_template_email.xml rename to addons/gift_card/data/mail_template_data.xml index b68bc35fe88..adefbb72867 100644 --- a/addons/gift_card/data/gift_card_template_email.xml +++ b/addons/gift_card/data/mail_template_data.xml @@ -33,5 +33,6 @@ + diff --git a/addons/repair/data/mail_template_data.xml b/addons/repair/data/mail_template_data.xml index 1fbdd62c191..7ba753b197b 100644 --- a/addons/repair/data/mail_template_data.xml +++ b/addons/repair/data/mail_template_data.xml @@ -7,7 +7,7 @@ ${object.partner_id.name} Repair Orders (Ref ${object.name or 'n/a' }) ${(object.create_uid.email_formatted or user.email_formatted) | safe} ${object.partner_id.id} - +

Hello ${object.partner_id.name},
diff --git a/addons/survey/data/mail_template_data.xml b/addons/survey/data/mail_template_data.xml index 413c455c40c..3fac468272f 100644 --- a/addons/survey/data/mail_template_data.xml +++ b/addons/survey/data/mail_template_data.xml @@ -43,31 +43,31 @@ Certification: ${object.survey_id.display_name} ${(object.survey_id.create_uid.email_formatted or user.email_formatted or user.company_id.catchall_formatted) |safe} ${(object.partner_id.email_formatted or object.email) |safe} - -

-
- - - -
- - - - Certification: ${object.survey_id.display_name} -
- - - - -
-

Dear ${object.partner_id.name or 'participant'}

-

- Here is, in attachment, your certification document for - ${object.survey_id.display_name} -

-

Congratulations for succeeding the test!

-
- + +
+ + + + +
+ + + + Certification: ${object.survey_id.display_name} +
+ + + + +
+

Dear ${object.partner_id.name or 'participant'}

+

+ Here is, in attachment, your certification document for + ${object.survey_id.display_name} +

+

Congratulations for succeeding the test!

+
+
Certification Document diff --git a/addons/test_mail/data/template_data.xml b/addons/test_mail/data/template_data.xml index d7a9248f449..cc307521e7b 100644 --- a/addons/test_mail/data/template_data.xml +++ b/addons/test_mail/data/template_data.xml @@ -4,7 +4,7 @@ Mail Test Full: Tracking Template Test Template ${object.customer_id.id | safe} -

Hello ${object.name}

+

Hello ${object.name}

@@ -13,7 +13,7 @@ Mail Test: Template Post on ${object.name} ${object.customer_id.id} -

Adding stuff on ${object.name}

+

Adding stuff on ${object.name}

diff --git a/addons/website_event_track/data/mail_template_data.xml b/addons/website_event_track/data/mail_template_data.xml index 3772e6fe60a..4df9ca0993a 100644 --- a/addons/website_event_track/data/mail_template_data.xml +++ b/addons/website_event_track/data/mail_template_data.xml @@ -5,7 +5,7 @@ Confirmation of ${object.name} - +
Dear ${object.partner_id.name or object.partner_name or ''}
We are pleased to inform you that your proposal ${object.name} has been accepted and confirmed for the event ${object.event_id.name}. diff --git a/addons/website_sale/data/mail_template_data.xml b/addons/website_sale/data/mail_template_data.xml index f63177338da..2eac01a9886 100644 --- a/addons/website_sale/data/mail_template_data.xml +++ b/addons/website_sale/data/mail_template_data.xml @@ -7,7 +7,7 @@ You left items in your cart! ${(object.user_id.email_formatted or user.email_formatted or '') | safe} ${object.partner_id.id} - +
From 69dfaf4ca5a17f880264b2d95f782c7136613e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 27 May 2021 15:02:33 +0000 Subject: [PATCH 9/9] [IMP] mail: experimentally support QWeb rendering Purpose of this commit is to prepare ground for future improvements by already supporting QWeb rendering in ``mail.render.mixin``. We temporarily support new field parameters allowing to tune the rendering directly from field. Notaby choosing engine (jinja or qweb) can be done from field directly. This is considered as experimental, used mainly to prepare QWeb support. 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 --- addons/mail/models/mail_render_mixin.py | 60 ++++++++++++++++++- addons/mail/tests/test_mail_render.py | 13 ++-- addons/test_mail/models/test_mail_models.py | 1 + .../tests/test_mail_composer_mixin.py | 3 + 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index 42e1261273d..7476becde46 100644 --- a/addons/mail/models/mail_render_mixin.py +++ b/addons/mail/models/mail_render_mixin.py @@ -8,6 +8,7 @@ import logging import re import dateutil.relativedelta as relativedelta +from lxml import html from markupsafe import Markup from werkzeug import urls @@ -166,6 +167,14 @@ class MailRenderMixin(models.AbstractModel): expression += "}" return expression + # ------------------------------------------------------------ + # ORM + # ------------------------------------------------------------ + + def _valid_field_parameter(self, field, name): + # allow specifying rendering options directly from field when using the render mixin + return name in ['render_engine', 'render_options'] or super()._valid_field_parameter(field, name) + # ------------------------------------------------------------ # TOOLS # ------------------------------------------------------------ @@ -280,6 +289,42 @@ class MailRenderMixin(models.AbstractModel): 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, options=None): + """ Render a raw QWeb template. + + :param str template_src: raw QWeb template to render; + :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_qweb_eval_context()``; + :param dict options: options for rendering (not used currently); + + :return dict: {res_id: string of rendered template based on record} + + :notice: Experimental. Use at your own risks only. + """ + results = dict.fromkeys(res_ids, u"") + + # prepare template variables + variables = self._render_qweb_eval_context() + if add_context: + variables.update(**add_context) + + for record in self.env[model].browse(res_ids): + variables['object'] = record + try: + render_result = self.env['ir.qweb']._render(html.fragment_fromstring(template_src), variables) + except Exception as e: + _logger.info("Failed to render template : %s", template_src, exc_info=True) + raise UserError(_("Failed to render QWeb template : %s)", e)) + results[record.id] = render_result + + return results + @api.model def _render_template_qweb_view(self, template_src, model, res_ids, add_context=None, options=None): @@ -434,12 +479,15 @@ class MailRenderMixin(models.AbstractModel): """ 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_view'): - raise ValueError(_('Template rendering supports only jinja or qweb.')) + if engine not in ('jinja', 'qweb', 'qweb_view'): + raise ValueError(_('Template rendering supports only raw jinja or qweb (view or raw).')) if engine == 'qweb_view': rendered = self._render_template_qweb_view(template_src, model, res_ids, add_context=add_context, options=options) + elif engine == 'qweb': + rendered = self._render_template_qweb(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, options=options) @@ -520,6 +568,9 @@ class MailRenderMixin(models.AbstractModel): :return dict: {res_id: string of rendered template based on record} """ + if options is None: + options = {} + self.ensure_one() if compute_lang: templates_res_ids = self._classify_per_lang(res_ids) @@ -528,6 +579,11 @@ class MailRenderMixin(models.AbstractModel): else: templates_res_ids = {self._context.get('lang'): (self, res_ids)} + # rendering options + engine = getattr(self._fields[field], 'render_engine', engine) + options.update(**getattr(self._fields[field], 'render_options', {})) + post_process = options.get('post_process') or post_process + return dict( (res_id, rendered) for lang, (template, tpl_res_ids) in templates_res_ids.items() diff --git a/addons/mail/tests/test_mail_render.py b/addons/mail/tests/test_mail_render.py index 7d1738c11bc..d3b3e19e7ae 100644 --- a/addons/mail/tests/test_mail_render.py +++ b/addons/mail/tests/test_mail_render.py @@ -274,20 +274,23 @@ class TestMailRender(common.MailCommon): # static xml src = '

This is a string

' expected = '

This is a string

' - for engine in ['jinja']: + for engine in ['jinja', 'qweb']: result = MailRenderMixin._render_template( src, partner._name, partner.ids, engine=engine, )[partner.id] - self.assertEqual(expected, result) + self.assertEqual(expected, result if engine == 'jinja' else result.decode()) # tde: checkme # code xml - src = '

This is a string with a number ${13+13}

' + srces = [ + '

This is a string with a number ${13+13}

', + '

This is a string with a number

', + ] expected = '

This is a string with a number 26

' - for engine in ['jinja']: + 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) + self.assertEqual(expected, result if engine == 'jinja' else result.decode()) # tde: checkme # condition block xml src = """Test diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index b0773d71c95..faa9593e892 100644 --- a/addons/test_mail/models/test_mail_models.py +++ b/addons/test_mail/models/test_mail_models.py @@ -132,6 +132,7 @@ class MailTestComposerMixin(models.Model): name = fields.Char('Name') author_id = fields.Many2one('res.partner') + description = fields.Html('Description', render_engine="qweb", render_options={"post_process": True}) def _compute_render_model(self): self.render_model = self._name diff --git a/addons/test_mail/tests/test_mail_composer_mixin.py b/addons/test_mail/tests/test_mail_composer_mixin.py index 265a509da9d..0b4ecf43d2c 100644 --- a/addons/test_mail/tests/test_mail_composer_mixin.py +++ b/addons/test_mail/tests/test_mail_composer_mixin.py @@ -39,6 +39,7 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients): 'name': 'Invite', 'subject': 'Subject for ${object.name}', 'body': '

Content from ${user.name}

', + 'description': '

Description for

', }) self.assertEqual(record.subject, 'Subject for ${object.name}') self.assertEqual(record.body, '

Content from ${user.name}

') @@ -47,3 +48,5 @@ class TestMailComposerMixin(TestMailCommon, TestRecipients): self.assertEqual(subject, 'Subject for %s' % record.name) body = record._render_field('body', record.ids)[record.id] self.assertEqual(body, '

Content from %s

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

Description for

')