From 822f468059c9702b0898016e7bfbaf036a2c4036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 25 Feb 2020 11:49:27 +0000 Subject: [PATCH] [MOV] mail: move rendering code to mail render mixin We also have to update code calling directly the rendering itself. Indeed some code bits does some rendering directly on jinja-enabled input and not through templates. Those calls have to be updated accordingly. LINKS Task ID 1963529 Community PR odoo/odoo#32397 --- addons/digest/models/digest.py | 4 +- addons/mail/models/mail_render_mixin.py | 186 +++++++++++++++++- addons/mail/models/mail_template.py | 174 ---------------- addons/mail/wizard/mail_compose_message.py | 8 +- addons/mass_mailing/models/__init__.py | 2 +- ...{mail_template.py => mail_render_mixin.py} | 6 +- addons/portal/__init__.py | 2 +- addons/sms/models/sms_template.py | 5 - addons/sms/wizard/sms_composer.py | 2 +- addons/survey/wizard/survey_invite.py | 4 +- .../wizard/slide_channel_invite.py | 4 +- 11 files changed, 201 insertions(+), 196 deletions(-) rename addons/mass_mailing/models/{mail_template.py => mail_render_mixin.py} (79%) diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py index 83a869361c6..8e5623f4dad 100644 --- a/addons/digest/models/digest.py +++ b/addons/digest/models/digest.py @@ -136,8 +136,8 @@ class Digest(models.Model): return False tip.user_ids += user body = tools.html_sanitize(tip.tip_description) - # FIXME: sanitize ? links , - tip_description = self.env['mail.template']._render_template(body, 'digest.tip', self.ids)[self.id] + # FIXME: sanitize ? links ? + tip_description = self.env['mail.render.mixin']._render_template(body, 'digest.tip', self.ids)[self.id] return tip_description def compute_kpis_actions(self, company, user): diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index eb5bcbef882..cbb70d3aacf 100644 --- a/addons/mail/models/mail_render_mixin.py +++ b/addons/mail/models/mail_render_mixin.py @@ -1,7 +1,79 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +import babel +import copy +import datetime +import dateutil.relativedelta as relativedelta +import functools +import logging + +from werkzeug import urls + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +def format_date(env, date, pattern=False, lang_code=False): + try: + return tools.format_date(env, date, date_format=pattern, lang_code=lang_code) + except babel.core.UnknownLocaleError: + return date + + +def format_datetime(env, dt, tz=False, dt_format='medium', lang_code=False): + try: + return tools.format_datetime(env, dt, tz=tz, dt_format=dt_format, lang_code=lang_code) + except babel.core.UnknownLocaleError: + return dt + +try: + # We use a jinja2 sandboxed environment to render mako templates. + # Note that the rendering does not cover all the mako syntax, in particular + # arbitrary Python statements are not accepted, and not all expressions are + # allowed: only "public" attributes (not starting with '_') of objects may + # be accessed. + # This is done on purpose: it prevents incidental or malicious execution of + # Python code that may break the security of the server. + from jinja2.sandbox import SandboxedEnvironment + jinja_template_env = SandboxedEnvironment( + block_start_string="<%", + block_end_string="%>", + variable_start_string="${", + variable_end_string="}", + comment_start_string="<%doc>", + comment_end_string="", + line_statement_prefix="%", + line_comment_prefix="##", + 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': 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_safe_template_env = copy.copy(jinja_template_env) + jinja_safe_template_env.autoescape = False +except ImportError: + _logger.warning("jinja2 not available, templating features will not work!") class MailRenderMixin(models.AbstractModel): @@ -66,3 +138,115 @@ class MailRenderMixin(models.AbstractModel): expression += " or '''%s'''" % null_value expression += "}" return expression + + # ------------------------------------------------------------ + # RENDERING + # ------------------------------------------------------------ + + @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_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 + + @api.model + def _render_template_jinja(self, template_txt, model, res_ids): + """ 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 + + * ``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) + + :return dict: {res_id: string of rendered template based on record} + """ + # TDE FIXME: remove that brol (6dde919bb9850912f618b561cd2141bffe41340c) + no_autoescape = self._context.get('safe') + results = dict.fromkeys(res_ids, u"") + if not template_txt: + return results + + # try to load the template + try: + jinja_env = jinja_safe_template_env if no_autoescape else jinja_template_env + template = jinja_env.from_string(tools.ustr(template_txt)) + except Exception: + _logger.info("Failed to load template %r", template_txt, exc_info=True) + return results + + # prepare template variables + variables = self._render_jinja_eval_context() + # 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) + raise UserError(_("Failed to render template : %s") % e) + if render_result == u"False": + render_result = u"" + results[record.id] = render_result + + return results + + @api.model + 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'). + + :param rendered: result of ``_render_template`` + + :return dict: updated version of rendered + """ + for res_id, html in rendered.items(): + rendered[res_id] = self.env['mail.thread']._replace_local_links(html) + return rendered + + @api.model + def _render_template(self, template_txt, model, res_ids, engine='jinja', post_process=False): + """ Render the given string on records designed by model / res_ids using + the given rendering engine. Currently only jinja is supported. + + :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 string engine: jinja + :param post_process: perform rendered str / html post processing (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 != 'jinja': + raise ValueError(_('Template rendering supports only jinja.')) + + rendered = self._render_template_jinja(template_txt, model, res_ids) + if post_process: + rendered = self._render_template_postprocess(rendered) + + return rendered diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index df14bfef5f7..72183aabf01 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -1,15 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import babel import base64 -import copy -import datetime -import dateutil.relativedelta as relativedelta -import functools import logging -from werkzeug import urls from odoo import _, api, fields, models, tools from odoo.exceptions import UserError @@ -17,66 +11,6 @@ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) -def format_date(env, date, pattern=False, lang_code=False): - try: - return tools.format_date(env, date, date_format=pattern, lang_code=lang_code) - except babel.core.UnknownLocaleError: - return date - - -def format_datetime(env, dt, tz=False, dt_format='medium', lang_code=False): - try: - return tools.format_datetime(env, dt, tz=tz, dt_format=dt_format, lang_code=lang_code) - except babel.core.UnknownLocaleError: - return dt - -try: - # We use a jinja2 sandboxed environment to render mako templates. - # Note that the rendering does not cover all the mako syntax, in particular - # arbitrary Python statements are not accepted, and not all expressions are - # allowed: only "public" attributes (not starting with '_') of objects may - # be accessed. - # This is done on purpose: it prevents incidental or malicious execution of - # Python code that may break the security of the server. - from jinja2.sandbox import SandboxedEnvironment - jinja_template_env = SandboxedEnvironment( - block_start_string="<%", - block_end_string="%>", - variable_start_string="${", - variable_end_string="}", - comment_start_string="<%doc>", - comment_end_string="", - line_statement_prefix="%", - line_comment_prefix="##", - 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': 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_safe_template_env = copy.copy(jinja_template_env) - jinja_safe_template_env.autoescape = False -except ImportError: - _logger.warning("jinja2 not available, templating features will not work!") - - class MailTemplate(models.Model): "Templates for sending email" _name = "mail.template" @@ -180,114 +114,6 @@ class MailTemplate(models.Model): # RENDERING # ------------------------------------------------------------ - @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_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 - - @api.model - def _render_template_jinja(self, template_txt, model, res_ids): - """ 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 - - * ``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) - - :return dict: {res_id: string of rendered template based on record} - """ - # TDE FIXME: remove that brol (6dde919bb9850912f618b561cd2141bffe41340c) - no_autoescape = self._context.get('safe') - results = dict.fromkeys(res_ids, u"") - if not template_txt: - return results - - # try to load the template - try: - jinja_env = jinja_safe_template_env if no_autoescape else jinja_template_env - template = jinja_env.from_string(tools.ustr(template_txt)) - except Exception: - _logger.info("Failed to load template %r", template_txt, exc_info=True) - return results - - # prepare template variables - variables = self._render_jinja_eval_context() - # 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) - raise UserError(_("Failed to render template : %s") % e) - if render_result == u"False": - render_result = u"" - results[record.id] = render_result - - return results - - @api.model - 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'). - - :param rendered: result of ``_render_template`` - - :return dict: updated version of rendered - """ - for res_id, html in rendered.items(): - rendered[res_id] = self.env['mail.thread']._replace_local_links(html) - return rendered - - @api.model - def _render_template(self, template_txt, model, res_ids, engine='jinja', post_process=False): - """ Render the given string on records designed by model / res_ids using - the given rendering engine. Currently only jinja is supported. - - :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 string engine: jinja - :param post_process: perform rendered str / html post processing (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 != 'jinja': - raise ValueError(_('Template rendering supports only jinja.')) - - rendered = self._render_template_jinja(template_txt, model, res_ids) - if post_process: - rendered = self._render_template_postprocess(rendered) - - return rendered - def _render_lang(self, res_ids): """ Given some record ids, return the lang for each record based on lang field of template or through specific context-based key. diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index fdc7b23508b..6c2e83baaec 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -455,10 +455,10 @@ class MailComposer(models.TransientModel): multi_mode = False res_ids = [res_ids] - subjects = self.env['mail.template']._render_template(self.subject, self.model, res_ids) - bodies = self.env['mail.template']._render_template(self.body, self.model, res_ids, post_process=True) - emails_from = self.env['mail.template']._render_template(self.email_from, self.model, res_ids) - replies_to = self.env['mail.template']._render_template(self.reply_to, self.model, 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) default_recipients = {} if not self.partner_ids: records = self.env[self.model].browse(res_ids).sudo() diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py index 64b451ac37b..035ce2d73cc 100644 --- a/addons/mass_mailing/models/__init__.py +++ b/addons/mass_mailing/models/__init__.py @@ -7,7 +7,7 @@ from . import mailing_list from . import mailing_trace from . import mailing from . import mail_mail -from . import mail_template +from . import mail_render_mixin from . import mail_thread from . import res_config_settings from . import res_users diff --git a/addons/mass_mailing/models/mail_template.py b/addons/mass_mailing/models/mail_render_mixin.py similarity index 79% rename from addons/mass_mailing/models/mail_template.py rename to addons/mass_mailing/models/mail_render_mixin.py index 93b6a0a0145..d38667366ed 100644 --- a/addons/mass_mailing/models/mail_template.py +++ b/addons/mass_mailing/models/mail_render_mixin.py @@ -4,13 +4,13 @@ from odoo import api, models -class MailTemplate(models.Model): - _inherit = "mail.template" +class MailRenderMixin(models.AbstractModel): + _inherit = "mail.render.mixin" @api.model def _render_template_postprocess(self, rendered): # super will transform relative url to absolute - rendered = super(MailTemplate, self)._render_template_postprocess(rendered) + rendered = super(MailRenderMixin, self)._render_template_postprocess(rendered) # apply shortener after if self.env.context.get('post_convert_links'): diff --git a/addons/portal/__init__.py b/addons/portal/__init__.py index db7d3b368c8..72caddbddb3 100644 --- a/addons/portal/__init__.py +++ b/addons/portal/__init__.py @@ -3,7 +3,7 @@ # Updating mako environement in order to be able to use slug try: - from odoo.addons.mail.models.mail_template import jinja_template_env, jinja_safe_template_env + from odoo.addons.mail.models.mail_render_mixin import jinja_template_env, jinja_safe_template_env from odoo.addons.http_routing.models.ir_http import slug jinja_template_env.globals.update({ diff --git a/addons/sms/models/sms_template.py b/addons/sms/models/sms_template.py index 46c8841dc2a..a2b2300051b 100644 --- a/addons/sms/models/sms_template.py +++ b/addons/sms/models/sms_template.py @@ -107,8 +107,3 @@ class SMSTemplate(models.Model): template = self.with_context(lang=lang) all_bodies.update(template._render_template(template.body, self.model, rids)) return all_bodies - - @api.model - def _render_template(self, template_txt, model, res_ids): - """ Render the jinja template """ - return self.env['mail.template']._render_template(template_txt, model, res_ids) diff --git a/addons/sms/wizard/sms_composer.py b/addons/sms/wizard/sms_composer.py index df76d2e9c60..60c915d8172 100644 --- a/addons/sms/wizard/sms_composer.py +++ b/addons/sms/wizard/sms_composer.py @@ -281,7 +281,7 @@ class SendSMS(models.TransientModel): if self.template_id and self.body == self.template_id.body: all_bodies = self.template_id._get_translated_bodies(records.ids) else: - all_bodies = self.env['mail.template']._render_template(self.body, records._name, records.ids) + all_bodies = self.env['mail.render.mixin']._render_template(self.body, records._name, records.ids) return all_bodies def _prepare_mass_sms_values(self, records): diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index 3450bd4f8cb..0068ecdb06c 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -199,8 +199,8 @@ class SurveyInvite(models.TransientModel): def _send_mail(self, answer): """ Create mail specific for recipient containing notably its access token """ - subject = self.env['mail.template']._render_template(self.subject, 'survey.user_input', answer.ids, post_process=True)[answer.id] - body = self.env['mail.template']._render_template(self.body, 'survey.user_input', answer.ids, post_process=True)[answer.id] + subject = self.env['mail.render.mixin']._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] # post the message mail_values = { 'email_from': self.email_from, diff --git a/addons/website_slides/wizard/slide_channel_invite.py b/addons/website_slides/wizard/slide_channel_invite.py index 6dc17de6f94..28522e39c4b 100644 --- a/addons/website_slides/wizard/slide_channel_invite.py +++ b/addons/website_slides/wizard/slide_channel_invite.py @@ -95,8 +95,8 @@ class SlideChannelInvite(models.TransientModel): def _prepare_mail_values(self, slide_channel_partner): """ Create mail specific for recipient """ - subject = self.env['mail.template']._render_template(self.subject, 'slide.channel.partner', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] - body = self.env['mail.template']._render_template(self.body, 'slide.channel.partner', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] + 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] # post the message mail_values = { 'email_from': self.email_from,