1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import babel
5import copy
6import functools
7import logging
8import re
9
10import dateutil.relativedelta as relativedelta
11from werkzeug import urls
12
13from odoo import _, api, fields, models, tools
14from odoo.exceptions import UserError
15from odoo.tools import safe_eval
16
17_logger = logging.getLogger(__name__)
18
19
20def format_date(env, date, pattern=False, lang_code=False):
21    try:
22        return tools.format_date(env, date, date_format=pattern, lang_code=lang_code)
23    except babel.core.UnknownLocaleError:
24        return date
25
26
27def format_datetime(env, dt, tz=False, dt_format='medium', lang_code=False):
28    try:
29        return tools.format_datetime(env, dt, tz=tz, dt_format=dt_format, lang_code=lang_code)
30    except babel.core.UnknownLocaleError:
31        return dt
32
33try:
34    # We use a jinja2 sandboxed environment to render mako templates.
35    # Note that the rendering does not cover all the mako syntax, in particular
36    # arbitrary Python statements are not accepted, and not all expressions are
37    # allowed: only "public" attributes (not starting with '_') of objects may
38    # be accessed.
39    # This is done on purpose: it prevents incidental or malicious execution of
40    # Python code that may break the security of the server.
41    from jinja2.sandbox import SandboxedEnvironment
42    jinja_template_env = SandboxedEnvironment(
43        block_start_string="<%",
44        block_end_string="%>",
45        variable_start_string="${",
46        variable_end_string="}",
47        comment_start_string="<%doc>",
48        comment_end_string="</%doc>",
49        line_statement_prefix="%",
50        line_comment_prefix="##",
51        trim_blocks=True,               # do not output newline after blocks
52        autoescape=True,                # XML/HTML automatic escaping
53    )
54    jinja_template_env.globals.update({
55        'str': str,
56        'quote': urls.url_quote,
57        'urlencode': urls.url_encode,
58        'datetime': safe_eval.datetime,
59        'len': len,
60        'abs': abs,
61        'min': min,
62        'max': max,
63        'sum': sum,
64        'filter': filter,
65        'reduce': functools.reduce,
66        'map': map,
67        'round': round,
68
69        # dateutil.relativedelta is an old-style class and cannot be directly
70        # instanciated wihtin a jinja2 expression, so a lambda "proxy" is
71        # is needed, apparently.
72        'relativedelta': lambda *a, **kw : relativedelta.relativedelta(*a, **kw),
73    })
74    jinja_safe_template_env = copy.copy(jinja_template_env)
75    jinja_safe_template_env.autoescape = False
76except ImportError:
77    _logger.warning("jinja2 not available, templating features will not work!")
78
79
80class MailRenderMixin(models.AbstractModel):
81    _name = 'mail.render.mixin'
82    _description = 'Mail Render Mixin'
83
84    # language for rendering
85    lang = fields.Char(
86        'Language',
87        help="Optional translation language (ISO code) to select when sending out an email. "
88             "If not set, the english version will be used. This should usually be a placeholder expression "
89             "that provides the appropriate language, e.g. ${object.partner_id.lang}.")
90    # expression builder
91    model_object_field = fields.Many2one(
92        'ir.model.fields', string="Field", store=False,
93        help="Select target field from the related document model.\n"
94             "If it is a relationship field you will be able to select "
95             "a target field at the destination of the relationship.")
96    sub_object = fields.Many2one(
97        'ir.model', 'Sub-model', readonly=True, store=False,
98        help="When a relationship field is selected as first field, "
99             "this field shows the document model the relationship goes to.")
100    sub_model_object_field = fields.Many2one(
101        'ir.model.fields', 'Sub-field', store=False,
102        help="When a relationship field is selected as first field, "
103             "this field lets you select the target field within the "
104             "destination document model (sub-model).")
105    null_value = fields.Char('Default Value', store=False, help="Optional value to use if the target field is empty")
106    copyvalue = fields.Char(
107        'Placeholder Expression', store=False,
108        help="Final placeholder expression, to be copy-pasted in the desired template field.")
109
110    @api.onchange('model_object_field', 'sub_model_object_field', 'null_value')
111    def _onchange_dynamic_placeholder(self):
112        """ Generate the dynamic placeholder """
113        if self.model_object_field:
114            if self.model_object_field.ttype in ['many2one', 'one2many', 'many2many']:
115                model = self.env['ir.model']._get(self.model_object_field.relation)
116                if model:
117                    self.sub_object = model.id
118                    sub_field_name = self.sub_model_object_field.name
119                    self.copyvalue = self._build_expression(self.model_object_field.name,
120                                                            sub_field_name, self.null_value or False)
121            else:
122                self.sub_object = False
123                self.sub_model_object_field = False
124                self.copyvalue = self._build_expression(self.model_object_field.name, False, self.null_value or False)
125        else:
126            self.sub_object = False
127            self.copyvalue = False
128            self.sub_model_object_field = False
129            self.null_value = False
130
131    @api.model
132    def _build_expression(self, field_name, sub_field_name, null_value):
133        """Returns a placeholder expression for use in a template field,
134        based on the values provided in the placeholder assistant.
135
136        :param field_name: main field name
137        :param sub_field_name: sub field name (M2O)
138        :param null_value: default value if the target value is empty
139        :return: final placeholder expression """
140        expression = ''
141        if field_name:
142            expression = "${object." + field_name
143            if sub_field_name:
144                expression += "." + sub_field_name
145            if null_value:
146                expression += " or '''%s'''" % null_value
147            expression += "}"
148        return expression
149
150    # ------------------------------------------------------------
151    # TOOLS
152    # ------------------------------------------------------------
153
154    def _replace_local_links(self, html, base_url=None):
155        """ Replace local links by absolute links. It is required in various
156        cases, for example when sending emails on chatter or sending mass
157        mailings. It replaces
158
159         * href of links (mailto will not match the regex)
160         * src of images (base64 hardcoded data will not match the regex)
161         * styling using url like background-image: url
162
163        It is done using regex because it is shorten than using an html parser
164        to create a potentially complex soupe and hope to have a result that
165        has not been harmed.
166        """
167        if not html:
168            return html
169
170        html = tools.ustr(html)
171
172        def _sub_relative2absolute(match):
173            # compute here to do it only if really necessary + cache will ensure it is done only once
174            # if not base_url
175            if not _sub_relative2absolute.base_url:
176                _sub_relative2absolute.base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
177            return match.group(1) + urls.url_join(_sub_relative2absolute.base_url, match.group(2))
178
179        _sub_relative2absolute.base_url = base_url
180        html = re.sub(r"""(<img(?=\s)[^>]*\ssrc=")(/[^/][^"]+)""", _sub_relative2absolute, html)
181        html = re.sub(r"""(<a(?=\s)[^>]*\shref=")(/[^/][^"]+)""", _sub_relative2absolute, html)
182        html = re.sub(r"""(<[^>]+\bstyle="[^"]+\burl\('?)(/[^/'][^'")]+)""", _sub_relative2absolute, html)
183
184        return html
185
186    @api.model
187    def _render_encapsulate(self, layout_xmlid, html, add_context=None, context_record=None):
188        try:
189            template = self.env.ref(layout_xmlid, raise_if_not_found=True)
190        except ValueError:
191            _logger.warning('QWeb template %s not found when rendering encapsulation template.' % (layout_xmlid))
192        else:
193            record_name = context_record.display_name if context_record else ''
194            model_description = self.env['ir.model']._get(context_record._name).display_name if context_record else False
195            template_ctx = {
196                'body': html,
197                'record_name': record_name,
198                'model_description': model_description,
199                'company': context_record['company_id'] if (context_record and 'company_id' in context_record) else self.env.company,
200                'record': context_record,
201            }
202            if add_context:
203                template_ctx.update(**add_context)
204
205            html = template._render(template_ctx, engine='ir.qweb', minimal_qcontext=True)
206            html = self.env['mail.render.mixin']._replace_local_links(html)
207        return html
208
209    @api.model
210    def _prepend_preview(self, html, preview):
211        """ Prepare the email body before sending. Add the text preview at the
212        beginning of the mail. The preview text is displayed bellow the mail
213        subject of most mail client (gmail, outlook...).
214
215        :param html: html content for which we want to prepend a preview
216        :param preview: the preview to add before the html content
217        :return: html with preprended preview
218        """
219        if preview:
220            preview = preview.strip()
221
222        if preview:
223            html_preview = f"""
224                <div style="display:none;font-size:1px;height:0px;width:0px;opacity:0;">
225                  {tools.html_escape(preview)}
226                </div>
227            """
228            return tools.prepend_html_content(html, html_preview)
229        return html
230
231    # ------------------------------------------------------------
232    # RENDERING
233    # ------------------------------------------------------------
234
235    @api.model
236    def _render_qweb_eval_context(self):
237        """ Prepare qweb evaluation context, containing for all rendering
238
239          * ``user``: current user browse record;
240          * ``ctx```: current context;
241          * various formatting tools;
242        """
243        render_context = {
244            'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code),
245            'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code),
246            'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code),
247            'format_duration': lambda value: tools.format_duration(value),
248            'user': self.env.user,
249            'ctx': self._context,
250        }
251        return render_context
252
253    @api.model
254    def _render_template_qweb(self, template_src, model, res_ids, add_context=None):
255        """ Render a QWeb template.
256
257        :param str template_src: source QWeb template. It should be a string
258          XmlID allowing to fetch an ir.ui.view;
259        :param str model: see ``MailRenderMixin._render_field)``;
260        :param list res_ids: see ``MailRenderMixin._render_field)``;
261
262        :param dict add_context: additional context to give to renderer. It
263          allows to add values to base rendering context generated by
264          ``MailRenderMixin._render_qweb_eval_context()``;
265
266        :return dict: {res_id: string of rendered template based on record}
267        """
268        view = self.env.ref(template_src, raise_if_not_found=False) or self.env['ir.ui.view']
269        results = dict.fromkeys(res_ids, u"")
270        if not view:
271            return results
272
273        # prepare template variables
274        variables = self._render_qweb_eval_context()
275        if add_context:
276            variables.update(**add_context)
277
278        for record in self.env[model].browse(res_ids):
279            variables['object'] = record
280            try:
281                render_result = view._render(variables, engine='ir.qweb', minimal_qcontext=True)
282            except Exception as e:
283                _logger.info("Failed to render template : %s (%d)" % (template_src, view.id), exc_info=True)
284                raise UserError(_("Failed to render template : %s (%d)", template_src, view.id))
285            results[record.id] = render_result
286
287        return results
288
289    @api.model
290    def _render_jinja_eval_context(self):
291        """ Prepare jinja evaluation context, containing for all rendering
292
293          * ``user``: current user browse record;
294          * ``ctx```: current context, named ctx to avoid clash with jinja
295            internals that already uses context;
296          * various formatting tools;
297        """
298        render_context = {
299            'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code),
300            'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code),
301            'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code),
302            'format_duration': lambda value: tools.format_duration(value),
303            'user': self.env.user,
304            'ctx': self._context,
305        }
306        return render_context
307
308    @api.model
309    def _render_template_jinja(self, template_txt, model, res_ids, add_context=None):
310        """ Render a string-based template on records given by a model and a list
311        of IDs, using jinja.
312
313        In addition to the generic evaluation context given by _render_jinja_eval_context
314        some new variables are added, depending on each record
315
316          * ``object``: record based on which the template is rendered;
317
318        :param str template_txt: template text to render
319        :param str model: model name of records on which we want to perform rendering
320        :param list res_ids: list of ids of records (all belonging to same model)
321
322        :return dict: {res_id: string of rendered template based on record}
323        """
324        # TDE FIXME: remove that brol (6dde919bb9850912f618b561cd2141bffe41340c)
325        no_autoescape = self._context.get('safe')
326        results = dict.fromkeys(res_ids, u"")
327        if not template_txt:
328            return results
329
330        # try to load the template
331        try:
332            jinja_env = jinja_safe_template_env if no_autoescape else jinja_template_env
333            template = jinja_env.from_string(tools.ustr(template_txt))
334        except Exception:
335            _logger.info("Failed to load template %r", template_txt, exc_info=True)
336            return results
337
338        # prepare template variables
339        variables = self._render_jinja_eval_context()
340        if add_context:
341            variables.update(**add_context)
342        safe_eval.check_values(variables)
343
344        # TDE CHECKME
345        # records = self.env[model].browse(it for it in res_ids if it)  # filter to avoid browsing [None]
346        if any(r is None for r in res_ids):
347            raise ValueError(_('Unsuspected None'))
348
349        for record in self.env[model].browse(res_ids):
350            variables['object'] = record
351            try:
352                render_result = template.render(variables)
353            except Exception as e:
354                _logger.info("Failed to render template : %s" % e, exc_info=True)
355                raise UserError(_("Failed to render template : %s", e))
356            if render_result == u"False":
357                render_result = u""
358            results[record.id] = render_result
359
360        return results
361
362    @api.model
363    def _render_template_postprocess(self, rendered):
364        """ Tool method for post processing. In this method we ensure local
365        links ('/shop/Basil-1') are replaced by global links ('https://www.
366        mygardin.com/hop/Basil-1').
367
368        :param rendered: result of ``_render_template``
369
370        :return dict: updated version of rendered
371        """
372        for res_id, html in rendered.items():
373            rendered[res_id] = self._replace_local_links(html)
374        return rendered
375
376    @api.model
377    def _render_template(self, template_src, model, res_ids, engine='jinja', add_context=None, post_process=False):
378        """ Render the given string on records designed by model / res_ids using
379        the given rendering engine. Currently only jinja or qweb are supported.
380
381        :param str template_src: template text to render (jinja) or xml id of view (qweb)
382          this could be cleaned but hey, we are in a rush
383        :param str model: model name of records on which we want to perform rendering
384        :param list res_ids: list of ids of records (all belonging to same model)
385        :param string engine: jinja
386        :param post_process: see ``MailRenderMixin._render_field``;
387
388        :return dict: {res_id: string of rendered template based on record}
389        """
390        if not isinstance(res_ids, (list, tuple)):
391            raise ValueError(_('Template rendering should be called only using on a list of IDs.'))
392        if engine not in ('jinja', 'qweb'):
393            raise ValueError(_('Template rendering supports only jinja or qweb.'))
394
395        if engine == 'qweb':
396            rendered = self._render_template_qweb(template_src, model, res_ids, add_context=add_context)
397        else:
398            rendered = self._render_template_jinja(template_src, model, res_ids, add_context=add_context)
399        if post_process:
400            rendered = self._render_template_postprocess(rendered)
401
402        return rendered
403
404    def _render_lang(self, res_ids):
405        """ Given some record ids, return the lang for each record based on
406        lang field of template or through specific context-based key.
407
408        :param list res_ids: list of ids of records (all belonging to same model
409          defined by self.model)
410
411        :return dict: {res_id: lang code (i.e. en_US)}
412        """
413        self.ensure_one()
414        if not isinstance(res_ids, (list, tuple)):
415            raise ValueError(_('Template rendering for language should be called with a list of IDs.'))
416
417        if self.env.context.get('template_preview_lang'):
418            return dict((res_id, self.env.context['template_preview_lang']) for res_id in res_ids)
419        else:
420            rendered_langs = self._render_template(self.lang, self.model, res_ids)
421            return dict((res_id, lang)
422                        for res_id, lang in rendered_langs.items())
423
424    def _classify_per_lang(self, res_ids):
425        """ Given some record ids, return for computed each lang a contextualized
426        template and its subset of res_ids.
427
428        :param list res_ids: list of ids of records (all belonging to same model
429          defined by self.model)
430
431        :return dict: {lang: (template with lang=lang_code if specific lang computed
432          or template, res_ids targeted by that language}
433        """
434        self.ensure_one()
435
436        lang_to_res_ids = {}
437        for res_id, lang in self._render_lang(res_ids).items():
438            lang_to_res_ids.setdefault(lang, []).append(res_id)
439
440        return dict(
441            (lang, (self.with_context(lang=lang) if lang else self, lang_res_ids))
442            for lang, lang_res_ids in lang_to_res_ids.items()
443        )
444
445    def _render_field(self, field, res_ids,
446                      compute_lang=False, set_lang=False,
447                      post_process=False):
448        """ Given some record ids, render a template located on field on all
449        records. ``field`` should be a field of self (i.e. ``body_html`` on
450        ``mail.template``). res_ids are record IDs linked to ``model`` field
451        on self.
452
453        :param list res_ids: list of ids of records (all belonging to same model
454          defined by ``self.model``)
455
456        :param boolean compute_lang: compute language to render on translated
457          version of the template instead of default (probably english) one.
458          Language will be computed based on ``self.lang``;
459        :param string set_lang: force language for rendering. It should be a
460          valid lang code matching an activate res.lang. Checked only if
461          ``compute_lang`` is False;
462        :param boolean post_process: perform a post processing on rendered result
463          (notably html links management). See``_render_template_postprocess``);
464
465        :return dict: {res_id: string of rendered template based on record}
466        """
467        self.ensure_one()
468        if compute_lang:
469            templates_res_ids = self._classify_per_lang(res_ids)
470        elif set_lang:
471            templates_res_ids = {set_lang: (self.with_context(lang=set_lang), res_ids)}
472        else:
473            templates_res_ids = {self._context.get('lang'): (self, res_ids)}
474
475        return dict(
476            (res_id, rendered)
477            for lang, (template, tpl_res_ids) in templates_res_ids.items()
478            for res_id, rendered in template._render_template(
479                template[field], template.model, tpl_res_ids,
480                post_process=post_process
481            ).items()
482        )
483