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/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): 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/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/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/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index 1f4b97c5ab4..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 @@ -37,6 +38,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 +82,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 +99,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 +121,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 """ @@ -154,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 # ------------------------------------------------------------ @@ -243,97 +264,150 @@ 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(self, template_src, model, res_ids, + add_context=None, options=None): + """ Render a raw QWeb template. - :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)``; + :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 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} + + :notice: Experimental. Use at your own risks only. """ - 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: - return results # 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): + """ 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_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} + """ + # 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: + return results + + # prepare template variables + 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 +426,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 +444,66 @@ 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'): - 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': - 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) + 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) + 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 +511,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 +542,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,11 +561,16 @@ 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``); :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) @@ -483,11 +579,16 @@ 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() 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..d3b3e19e7ae 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,134 @@ 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', 'qweb']: + result = MailRenderMixin._render_template( + src, partner._name, partner.ids, engine=engine, + )[partner.id] + self.assertEqual(expected, result if engine == 'jinja' else result.decode()) # tde: checkme + + # code xml + 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, src in zip(['jinja', 'qweb'], srces): + result = MailRenderMixin._render_template( + src, partner._name, partner.ids, engine=engine, + )[partner.id] + self.assertEqual(expected, result if engine == 'jinja' else result.decode()) # tde: checkme + + # 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/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() 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/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/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/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/data/mail_template_data.xml b/addons/survey/data/mail_template_data.xml index 1e138281ec9..3fac468272f 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}

@@ -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/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index 48cfabb8f26..81a567d7066 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, options={'render_safe': True})[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 @@ + 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/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index 47fdf6ae1ed..faa9593e892 100644 --- a/addons/test_mail/models/test_mail_models.py +++ b/addons/test_mail/models/test_mail_models.py @@ -122,3 +122,17 @@ 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') + 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/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..0b4ecf43d2c --- /dev/null +++ b/addons/test_mail/tests/test_mail_composer_mixin.py @@ -0,0 +1,52 @@ +# -*- 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}

', + 'description': '

Description for

', + }) + 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) + description = record._render_field('description', record.ids)[record.id] + self.assertEqual(description, '

Description for

') 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'}) 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} - +
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/__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/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 737651e6a35..79698286ade 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): @@ -77,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: @@ -84,16 +70,14 @@ 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.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, 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 = { '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..543369289c8 100644 --- a/addons/website_slides/wizard/slide_channel_invite_views.xml +++ b/addons/website_slides/wizard/slide_channel_invite_views.xml @@ -11,9 +11,11 @@ + required="1" + context="{'force_email':True, 'show_email':True, 'no_create_edit': True, 'no_quick_create': True}"/> +