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