diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index bde55d46f4c..d6bb81c206b 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -2242,11 +2242,11 @@ class AccountMove(models.Model): """ self.ensure_one() template = self.env.ref('account.email_template_edi_invoice', raise_if_not_found=False) - lang = get_lang(self.env) - if template and template.lang: - lang = template._render_template(template.lang, 'account.move', self.id) - else: - lang = lang.code + lang = False + if template: + lang = template._render_lang(self.ids)[self.id] + if not lang: + lang = get_lang(self.env).code compose_form = self.env.ref('account.account_invoice_send_wizard_form', raise_if_not_found=False) ctx = dict( default_model='account.move', diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py index 432625588a8..8e5623f4dad 100644 --- a/addons/digest/models/digest.py +++ b/addons/digest/models/digest.py @@ -136,7 +136,8 @@ class Digest(models.Model): return False tip.user_ids += user body = tools.html_sanitize(tip.tip_description) - tip_description = self.env['mail.template']._render_template(body, 'digest.tip', self.id) + # FIXME: sanitize ? links ? + tip_description = self.env['mail.render.mixin']._render_template(body, 'digest.tip', self.ids)[self.id] return tip_description def compute_kpis_actions(self, company, user): diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py index 19aa131270e..c424a8c4b28 100644 --- a/addons/gamification/models/challenge.py +++ b/addons/gamification/models/challenge.py @@ -551,11 +551,10 @@ class Challenge(models.Model): challenge = self - MailTemplates = self.env['mail.template'] if challenge.visibility_mode == 'ranking': lines_boards = challenge._get_serialized_challenge_lines(restrict_goals=subset_goals) - body_html = MailTemplates.with_context(challenge_lines=lines_boards)._render_template(challenge.report_template_id.body_html, 'gamification.challenge', challenge.id) + body_html = challenge.report_template_id.with_context(challenge_lines=lines_boards)._render_field('body_html', challenge.ids)[challenge.id] # send to every follower and participant of the challenge challenge.message_post( @@ -576,10 +575,7 @@ class Challenge(models.Model): if not lines: continue - body_html = MailTemplates.with_user(user).with_context(challenge_lines=lines)._render_template( - challenge.report_template_id.body_html, - 'gamification.challenge', - challenge.id) + body_html = challenge.report_template_id.with_user(user).with_context(challenge_lines=lines)._render_field('body_html', challenge.ids)[challenge.id] # notify message only to users, do not post on the challenge challenge.message_notify( diff --git a/addons/gamification/models/goal.py b/addons/gamification/models/goal.py index d6dd9e5a919..3d528a5cab8 100644 --- a/addons/gamification/models/goal.py +++ b/addons/gamification/models/goal.py @@ -213,10 +213,7 @@ class Goal(models.Model): return {} # generate a reminder report - template = self.env.ref('gamification.email_template_goal_reminder')\ - .get_email_template(self.id) - body_html = self.env['mail.template'].with_context(template._context)\ - ._render_template(template.body_html, 'gamification.goal', self.id) + body_html = self.env.ref('gamification.email_template_goal_reminder')._render_field('body_html', self.ids, compute_lang=True)[self.id] self.message_notify( body=body_html, partner_ids=[self.user_id.partner_id.id], diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 1f77dcdc4d4..60a402c0015 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -647,7 +647,7 @@ class HrExpense(models.Model): mail_template_id = 'hr_expense.hr_expense_template_register' if expense.employee_id.user_id else 'hr_expense.hr_expense_template_register_no_user' expense_template = self.env.ref(mail_template_id) rendered_body = expense_template.render({'expense': expense}, engine='ir.qweb') - body = self.env['mail.thread']._replace_local_links(rendered_body) + body = self.env['mail.render.mixin']._replace_local_links(rendered_body) # TDE TODO: seems louche, check to use notify if expense.employee_id.user_id.partner_id: expense.message_post( diff --git a/addons/im_livechat/models/mail_channel.py b/addons/im_livechat/models/mail_channel.py index c7ca4098093..2a6f5548d66 100644 --- a/addons/im_livechat/models/mail_channel.py +++ b/addons/im_livechat/models/mail_channel.py @@ -184,7 +184,7 @@ class MailChannel(models.Model): } template = self.env.ref('im_livechat.livechat_email_template') mail_body = template.render(render_context, engine='ir.qweb', minimal_qcontext=True) - mail_body = self.env['mail.thread']._replace_local_links(mail_body) + mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body) mail = self.env['mail.mail'].sudo().create({ 'subject': _('Conversation with %s') % self.livechat_operator_id.name, 'email_from': company.catchall_formatted or company.email_formatted, diff --git a/addons/l10n_ch/models/mail_template.py b/addons/l10n_ch/models/mail_template.py index b871078bde9..b532acc9b23 100644 --- a/addons/l10n_ch/models/mail_template.py +++ b/addons/l10n_ch/models/mail_template.py @@ -16,28 +16,27 @@ class MailTemplate(models.Model): present on the invoice. Otherwise, no ISR attachment will be created, and the mail will only contain the invoice (as defined in the mother method). """ - rslt = super(MailTemplate, self).generate_email(res_ids, fields) + result = super(MailTemplate, self).generate_email(res_ids, fields) + if self.model != 'account.move': + return result multi_mode = True if isinstance(res_ids, int): res_ids = [res_ids] multi_mode = False - res_ids_to_templates = self.get_email_template(res_ids) - for res_id in res_ids: - related_model = self.env[self.model_id.model].browse(res_id) + for record in self.env[self.model].browse(res_ids): + if record.l10n_ch_isr_valid: + # We add an attachment containing the ISR + report_name = 'ISR-' + self._render_field('report_name', record.ids, compute_lang=True)[record.id] + '.pdf' - if related_model._name == 'account.move' and related_model.l10n_ch_isr_valid: - #We add an attachment containing the ISR - template = res_ids_to_templates[res_id] - report_name = 'ISR-' + self._render_template(template.report_name, template.model, res_id) + '.pdf' - - pdf = self.env.ref('l10n_ch.l10n_ch_isr_report').render_qweb_pdf([res_id])[0] + pdf = self.env.ref('l10n_ch.l10n_ch_isr_report').render_qweb_pdf(record.ids)[0] pdf = base64.b64encode(pdf) - attachments_list = multi_mode and rslt[res_id].get('attachments', False) or rslt.get('attachments', False) + record_dict = multi_mode and result[record.id] or result + attachments_list = record_dict.get('attachments', False) if attachments_list: attachments_list.append((report_name, pdf)) else: - rslt[res_id]['attachments'] = [(report_name, pdf)] - return rslt + record_dict['attachments'] = [(report_name, pdf)] + return result diff --git a/addons/link_tracker/__manifest__.py b/addons/link_tracker/__manifest__.py index 3f622632cc4..038fe807173 100644 --- a/addons/link_tracker/__manifest__.py +++ b/addons/link_tracker/__manifest__.py @@ -8,7 +8,7 @@ Shorten URLs and use them to track clicks and UTMs """, 'version': '1.0', - 'depends': ['utm'], + 'depends': ['utm', 'mail'], 'data': [ 'views/link_tracker_views.xml', 'views/utm_campaign_views.xml', diff --git a/addons/link_tracker/models/__init__.py b/addons/link_tracker/models/__init__.py index 847f935569e..6f884bd7c2e 100644 --- a/addons/link_tracker/models/__init__.py +++ b/addons/link_tracker/models/__init__.py @@ -2,4 +2,5 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import link_tracker +from . import mail_render_mixin from . import utm diff --git a/addons/link_tracker/models/link_tracker.py b/addons/link_tracker/models/link_tracker.py index 3af4accb9ba..ec73046e1be 100644 --- a/addons/link_tracker/models/link_tracker.py +++ b/addons/link_tracker/models/link_tracker.py @@ -1,28 +1,14 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import base64 -import datetime import random -import re import requests import string from lxml import html -from werkzeug import urls, utils +from werkzeug import urls -from odoo import models, fields, api, _ - - -URL_REGEX = r'(\bhref=[\'"](?!mailto:|tel:|sms:)([^\'"]+)[\'"])' -TEXT_URL_REGEX = r'https?://[a-zA-Z0-9@:%._\+~#=/-]+(?:\?\S+)?' - - -def VALIDATE_URL(url): - if urls.url_parse(url).scheme not in ('http', 'https', 'ftp', 'ftps'): - return 'http://' + url - - return url +from odoo import tools, models, fields, api, _ class LinkTracker(models.Model): @@ -101,7 +87,7 @@ class LinkTracker(models.Model): if 'url' not in create_vals: raise ValueError('URL field required') else: - create_vals['url'] = VALIDATE_URL(vals['url']) + create_vals['url'] = tools.validate_url(vals['url']) search_domain = [] for fname, value in create_vals.items(): @@ -129,44 +115,10 @@ class LinkTracker(models.Model): @api.model def convert_links(self, html, vals, blacklist=None): - for match in re.findall(URL_REGEX, html): - - short_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/' - - href = match[0] - long_url = match[1] - - vals['url'] = utils.unescape(long_url) - - if not blacklist or not [s for s in blacklist if s in long_url] and not long_url.startswith(short_schema): - link = self.create(vals) - shorten_url = self.browse(link.id)[0].short_url - - if shorten_url: - new_href = href.replace(long_url, shorten_url) - html = html.replace(href, new_href) - - return html + raise NotImplementedError('Moved on mail.render.mixin') def _convert_links_text(self, body, vals, blacklist=None): - shortened_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/r/' - unsubscribe_schema = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + '/sms/' - for original_url in re.findall(TEXT_URL_REGEX, body): - # don't shorten already-shortened links or links towards unsubscribe page - if original_url.startswith(shortened_schema) or original_url.startswith(unsubscribe_schema): - continue - # support blacklist items in path, like /u/ - parsed = urls.url_parse(original_url, scheme='http') - if blacklist and any(item in parsed.path for item in blacklist): - continue - - vals['url'] = utils.unescape(original_url) - link = self.create(vals) - shortened_url = link.short_url - if shortened_url: - body = body.replace(original_url, shortened_url, 1) - - return body + raise NotImplementedError('Moved on mail.render.mixin') def action_view_statistics(self): action = self.env['ir.actions.act_window'].for_xml_id('link_tracker', 'link_tracker_click_action_statistics') diff --git a/addons/link_tracker/models/mail_render_mixin.py b/addons/link_tracker/models/mail_render_mixin.py new file mode 100644 index 00000000000..0bc30de960b --- /dev/null +++ b/addons/link_tracker/models/mail_render_mixin.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + +from werkzeug import urls, utils + +from odoo import api, models, tools + + +class MailRenderMixin(models.AbstractModel): + _inherit = "mail.render.mixin" + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + @api.model + def _shorten_links(self, html, link_tracker_vals, blacklist=None, base_url=None): + """ + + :param link_tracker_vals: values given to the created link.tracker, containing + for example: campaign_id, medium_id, source_id, and any other relevant fields + like mass_mailing_id in mass_mailing; + + :return: update + """ + base_url = base_url or self.env['ir.config_parameter'].sudo().get_param('web.base.url') + short_schema = base_url + '/r/' + for match in re.findall(tools.URL_REGEX, html): + href = match[0] + long_url = match[1] + + if not blacklist or not [s for s in blacklist if s in long_url] and not long_url.startswith(short_schema): + create_vals = dict(link_tracker_vals, url=utils.unescape(long_url)) + link = self.env['link.tracker'].create(create_vals) + if link.short_url: + new_href = href.replace(long_url, link.short_url) + html = html.replace(href, new_href) + + return html + + @api.model + def _shorten_links_text(self, html, link_tracker_vals, blacklist=None, base_url=None): + base_url = base_url or self.env['ir.config_parameter'].sudo().get_param('web.base.url') + shortened_schema = base_url + '/r/' + unsubscribe_schema = base_url + '/sms/' + for original_url in re.findall(tools.TEXT_URL_REGEX, html): + # don't shorten already-shortened links or links towards unsubscribe page + if original_url.startswith(shortened_schema) or original_url.startswith(unsubscribe_schema): + continue + # support blacklist items in path, like /u/ + parsed = urls.url_parse(original_url, scheme='http') + if blacklist and any(item in parsed.path for item in blacklist): + continue + + create_vals = dict(link_tracker_vals, url= utils.unescape(original_url)) + link = self.env['link.tracker'].create(create_vals) + if link.short_url: + html = html.replace(original_url, link.short_url, 1) + + return html diff --git a/addons/link_tracker/models/utm.py b/addons/link_tracker/models/utm.py index ebb343472e7..75eff850150 100644 --- a/addons/link_tracker/models/utm.py +++ b/addons/link_tracker/models/utm.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models, api, SUPERUSER_ID +from odoo import fields, models class UtmCampaign(models.Model): diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index c2fafe1d56e..ee989342b42 100644 --- a/addons/mail/models/mail_render_mixin.py +++ b/addons/mail/models/mail_render_mixin.py @@ -1,30 +1,110 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +import babel +import copy +import datetime +import dateutil.relativedelta as relativedelta +import functools +import logging +import re + +from werkzeug import urls + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +def format_date(env, date, pattern=False, lang_code=False): + try: + return tools.format_date(env, date, date_format=pattern, lang_code=lang_code) + except babel.core.UnknownLocaleError: + return date + + +def format_datetime(env, dt, tz=False, dt_format='medium', lang_code=False): + try: + return tools.format_datetime(env, dt, tz=tz, dt_format=dt_format, lang_code=lang_code) + except babel.core.UnknownLocaleError: + return dt + +try: + # We use a jinja2 sandboxed environment to render mako templates. + # Note that the rendering does not cover all the mako syntax, in particular + # arbitrary Python statements are not accepted, and not all expressions are + # allowed: only "public" attributes (not starting with '_') of objects may + # be accessed. + # This is done on purpose: it prevents incidental or malicious execution of + # Python code that may break the security of the server. + from jinja2.sandbox import SandboxedEnvironment + jinja_template_env = SandboxedEnvironment( + block_start_string="<%", + block_end_string="%>", + variable_start_string="${", + variable_end_string="}", + comment_start_string="<%doc>", + comment_end_string="", + line_statement_prefix="%", + line_comment_prefix="##", + trim_blocks=True, # do not output newline after blocks + autoescape=True, # XML/HTML automatic escaping + ) + jinja_template_env.globals.update({ + 'str': str, + 'quote': urls.url_quote, + 'urlencode': urls.url_encode, + 'datetime': datetime, + 'len': len, + 'abs': abs, + 'min': min, + 'max': max, + 'sum': sum, + 'filter': filter, + 'reduce': functools.reduce, + 'map': map, + 'round': round, + + # dateutil.relativedelta is an old-style class and cannot be directly + # instanciated wihtin a jinja2 expression, so a lambda "proxy" is + # is needed, apparently. + 'relativedelta': lambda *a, **kw : relativedelta.relativedelta(*a, **kw), + }) + jinja_safe_template_env = copy.copy(jinja_template_env) + jinja_safe_template_env.autoescape = False +except ImportError: + _logger.warning("jinja2 not available, templating features will not work!") + class MailRenderMixin(models.AbstractModel): - _name = 'mail.render.mixin' _description = 'Mail Render Mixin' + # language for rendering + lang = fields.Char( + 'Language', placeholder="${object.partner_id.lang}", + 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}.") + # expression builder model_object_field = fields.Many2one( 'ir.model.fields', string="Field", store=False, help="Select target field from the related document model.\n" - "If it is a relationship field you will be able to select " - "a target field at the destination of the relationship.") + "If it is a relationship field you will be able to select " + "a target field at the destination of the relationship.") sub_object = fields.Many2one( 'ir.model', 'Sub-model', readonly=True, store=False, help="When a relationship field is selected as first field, " - "this field shows the document model the relationship goes to.") + "this field shows the document model the relationship goes to.") sub_model_object_field = fields.Many2one( 'ir.model.fields', 'Sub-field', store=False, help="When a relationship field is selected as first field, " - "this field lets you select the target field within the " - "destination document model (sub-model).") + "this field lets you select the target field within the " + "destination document model (sub-model).") null_value = fields.Char('Default Value', store=False, help="Optional value to use if the target field is empty") copyvalue = fields.Char( - 'Placeholder Expression',store=False, + 'Placeholder Expression', store=False, help="Final placeholder expression, to be copy-pasted in the desired template field.") @api.onchange('model_object_field', 'sub_model_object_field', 'null_value') @@ -65,4 +145,223 @@ class MailRenderMixin(models.AbstractModel): if null_value: expression += " or '''%s'''" % null_value expression += "}" - return expression + return expression + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + def _replace_local_links(self, html, base_url=None): + """ Replace local links by absolute links. It is required in various + cases, for example when sending emails on chatter or sending mass + mailings. It replaces + + * href of links (mailto will not match the regex) + * src of images (base64 hardcoded data will not match the regex) + * styling using url like background-image: url + + It is done using regex because it is shorten than using an html parser + to create a potentially complex soupe and hope to have a result that + has not been harmed. + """ + if not html: + return html + + html = tools.ustr(html) + + def _sub_relative2absolute(match): + # compute here to do it only if really necessary + cache will ensure it is done only once + # if not base_url + if not _sub_relative2absolute.base_url: + _sub_relative2absolute.base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + return match.group(1) + urls.url_join(_sub_relative2absolute.base_url, match.group(2)) + + _sub_relative2absolute.base_url = base_url + html = re.sub(r"""(]*\ssrc=")(/[^/][^"]+)""", _sub_relative2absolute, html) + html = re.sub(r"""(]*\shref=")(/[^/][^"]+)""", _sub_relative2absolute, html) + html = re.sub(r"""(<[^>]+\bstyle="[^"]+\burl\('?)(/[^/'][^'")]+)""", _sub_relative2absolute, html) + + return html + + # ------------------------------------------------------------ + # RENDERING + # ------------------------------------------------------------ + + @api.model + def _render_jinja_eval_context(self): + """ Prepare jinja evaluation context, containing for all rendering + + * ``user``: current user browse record; + * ``ctx```: current context, named ctx to avoid clash with jinja + internals that already uses context; + * various formatting tools; + """ + render_context = { + 'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code), + 'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code), + 'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code), + 'format_duration': lambda value: tools.format_duration(value), + 'user': self.env.user, + 'ctx': self._context, + } + return render_context + + @api.model + def _render_template_jinja(self, template_txt, model, res_ids): + """ Render a string-based template on records given by a model and a list + of IDs, using jinja. + + In addition to the generic evaluation context given by _render_jinja_eval_context + some new variables are added, depending on each record + + * ``object``: record based on which the template is rendered; + + :param str template_txt: template text to render + :param str model: model name of records on which we want to perform rendering + :param list res_ids: list of ids of records (all belonging to same model) + + :return dict: {res_id: string of rendered template based on record} + """ + # TDE FIXME: remove that brol (6dde919bb9850912f618b561cd2141bffe41340c) + no_autoescape = self._context.get('safe') + results = dict.fromkeys(res_ids, u"") + if not template_txt: + return results + + # try to load the template + try: + jinja_env = jinja_safe_template_env if no_autoescape else jinja_template_env + template = jinja_env.from_string(tools.ustr(template_txt)) + except Exception: + _logger.info("Failed to load template %r", template_txt, exc_info=True) + return results + + # prepare template variables + variables = self._render_jinja_eval_context() + # TDE CHECKME + # records = self.env[model].browse(it for it in res_ids if it) # filter to avoid browsing [None] + if any(r is None for r in res_ids): + raise ValueError(_('Unsuspected None')) + + for record in self.env[model].browse(res_ids): + variables['object'] = record + try: + render_result = template.render(variables) + except Exception as e: + _logger.info("Failed to render template : %s" % e, exc_info=True) + raise UserError(_("Failed to render template : %s") % e) + if render_result == u"False": + render_result = u"" + results[record.id] = render_result + + return results + + @api.model + def _render_template_postprocess(self, rendered): + """ Tool method for post processing. In this method we ensure local + links ('/shop/Basil-1') are replaced by global links ('https://www. + mygardin.com/hop/Basil-1'). + + :param rendered: result of ``_render_template`` + + :return dict: updated version of rendered + """ + for res_id, html in rendered.items(): + rendered[res_id] = self._replace_local_links(html) + return rendered + + @api.model + def _render_template(self, template_txt, model, res_ids, engine='jinja', post_process=False): + """ Render the given string on records designed by model / res_ids using + the given rendering engine. Currently only jinja is supported. + + :param str template_txt: template text to render + :param str model: model name of records on which we want to perform rendering + :param list res_ids: list of ids of records (all belonging to same model) + :param string engine: jinja + :param post_process: perform rendered str / html post processing (see + ``_render_template_postprocess``) + + :return dict: {res_id: string of rendered template based on record} + """ + if not isinstance(res_ids, (list, tuple)): + raise ValueError(_('Template rendering should be called only using on a list of IDs.')) + if engine != 'jinja': + raise ValueError(_('Template rendering supports only jinja.')) + + rendered = self._render_template_jinja(template_txt, model, res_ids) + if post_process: + rendered = self._render_template_postprocess(rendered) + + return rendered + + def _render_lang(self, res_ids): + """ Given some record ids, return the lang for each record based on + lang field of template or through specific context-based key. + + :param list res_ids: list of ids of records (all belonging to same model + defined by self.model) + + :return dict: {res_id: lang code (i.e. en_US)} + """ + self.ensure_one() + 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()) + + def _classify_per_lang(self, res_ids): + """ 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) + + :return dict: {lang: (template with lang=lang_code if specific lang computed + or template, res_ids targeted by that language} + """ + self.ensure_one() + + lang_to_res_ids = {} + for res_id, lang in self._render_lang(res_ids).items(): + lang_to_res_ids.setdefault(lang, []).append(res_id) + + return dict( + (lang, (self.with_context(lang=lang) if lang else self, lang_res_ids)) + for lang, lang_res_ids in lang_to_res_ids.items() + ) + + def _render_field(self, field, res_ids, compute_lang=False, set_lang=False, post_process=False): + """ Given some record ids, render a given field of template rendered on + all records. + + :param list res_ids: list of ids of records (all belonging to same model + defined by self.model) + :param compute_lang: compute rendering language based on template.lang + :param set_lang: force language + :param post_process: perform rendered str / html post processing (see + ``_render_template_postprocess``) + + :return dict: {res_id: string of rendered template based on record} + """ + self.ensure_one() + if compute_lang: + templates_res_ids = self._classify_per_lang(res_ids) + elif set_lang: + templates_res_ids = {set_lang: (self.with_context(lang=set_lang), res_ids)} + else: + templates_res_ids = {self._context.get('lang'): (self, res_ids)} + + 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 + ).items() + ) diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index be6040a4025..c4d590e2729 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -1,15 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import babel import base64 -import copy -import datetime -import dateutil.relativedelta as relativedelta -import functools import logging -from werkzeug import urls from odoo import _, api, fields, models, tools from odoo.exceptions import UserError @@ -17,66 +11,6 @@ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) -def format_date(env, date, pattern=False, lang_code=False): - try: - return tools.format_date(env, date, date_format=pattern, lang_code=lang_code) - except babel.core.UnknownLocaleError: - return date - - -def format_datetime(env, dt, tz=False, dt_format='medium', lang_code=False): - try: - return tools.format_datetime(env, dt, tz=tz, dt_format=dt_format, lang_code=lang_code) - except babel.core.UnknownLocaleError: - return dt - -try: - # We use a jinja2 sandboxed environment to render mako templates. - # Note that the rendering does not cover all the mako syntax, in particular - # arbitrary Python statements are not accepted, and not all expressions are - # allowed: only "public" attributes (not starting with '_') of objects may - # be accessed. - # This is done on purpose: it prevents incidental or malicious execution of - # Python code that may break the security of the server. - from jinja2.sandbox import SandboxedEnvironment - mako_template_env = SandboxedEnvironment( - block_start_string="<%", - block_end_string="%>", - variable_start_string="${", - variable_end_string="}", - comment_start_string="<%doc>", - comment_end_string="", - line_statement_prefix="%", - line_comment_prefix="##", - trim_blocks=True, # do not output newline after blocks - autoescape=True, # XML/HTML automatic escaping - ) - mako_template_env.globals.update({ - 'str': str, - 'quote': urls.url_quote, - 'urlencode': urls.url_encode, - 'datetime': datetime, - 'len': len, - 'abs': abs, - 'min': min, - 'max': max, - 'sum': sum, - 'filter': filter, - 'reduce': functools.reduce, - 'map': map, - 'round': round, - - # dateutil.relativedelta is an old-style class and cannot be directly - # instanciated wihtin a jinja2 expression, so a lambda "proxy" is - # is needed, apparently. - 'relativedelta': lambda *a, **kw : relativedelta.relativedelta(*a, **kw), - }) - mako_safe_template_env = copy.copy(mako_template_env) - mako_safe_template_env.autoescape = False -except ImportError: - _logger.warning("jinja2 not available, templating features will not work!") - - class MailTemplate(models.Model): "Templates for sending email" _name = "mail.template" @@ -91,20 +25,15 @@ class MailTemplate(models.Model): res['model_id'] = self.env['ir.model']._get(res.pop('model')).id return res + # description name = fields.Char('Name') model_id = fields.Many2one('ir.model', 'Applies to', help="The type of document this template can be used with") model = fields.Char('Related Document Model', related='model_id.model', index=True, store=True, readonly=True) - lang = fields.Char('Language', - 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}.", - placeholder="${object.partner_id.lang}") subject = fields.Char('Subject', translate=True, help="Subject (placeholders may be used here)") email_from = fields.Char('From', help="Sender address (placeholders may be used here). If not set, the default " "value will be the author's email alias if configured, or email address.") + # recipients use_default_to = fields.Boolean( 'Default recipients', help="Default recipients of the record:\n" @@ -115,32 +44,28 @@ class MailTemplate(models.Model): help="Comma-separated ids of recipient partners (placeholders may be used here)") email_cc = fields.Char('Cc', help="Carbon copy recipients (placeholders may be used here)") reply_to = fields.Char('Reply-To', help="Preferred response address (placeholders may be used here)") - mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing Mail Server', readonly=False, - help="Optional preferred server for outgoing mails. If not set, the highest " - "priority one will be used.") + # content body_html = fields.Html('Body', translate=True, sanitize=False) - report_name = fields.Char('Report Filename', translate=True, - help="Name to use for the generated report file (may contain placeholders)\n" - "The extension can be omitted and will then come from the report type.") - report_template = fields.Many2one('ir.actions.report', 'Optional report to print and attach') - ref_ir_act_window = fields.Many2one('ir.actions.act_window', 'Sidebar action', readonly=True, copy=False, - help="Sidebar action to make this template available on records " - "of the related document model") attachment_ids = fields.Many2many('ir.attachment', 'email_template_attachment_rel', 'email_template_id', 'attachment_id', 'Attachments', help="You may attach files to this template, to be added to all " "emails created from this template") - auto_delete = fields.Boolean('Auto Delete', default=True, help="This option permanently removes any track of email after send, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.") - + report_name = fields.Char('Report Filename', translate=True, + help="Name to use for the generated report file (may contain placeholders)\n" + "The extension can be omitted and will then come from the report type.") + report_template = fields.Many2one('ir.actions.report', 'Optional report to print and attach') + # options + mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing Mail Server', readonly=False, + help="Optional preferred server for outgoing mails. If not set, the highest " + "priority one will be used.") scheduled_date = fields.Char('Scheduled Date', help="If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible. Jinja2 placeholders may be used.") - - @api.onchange('model_id') - def onchange_model_id(self): - # TDE CLEANME: should'nt it be a stored related ? - if self.model_id: - self.model = self.model_id.model - else: - self.model = False + auto_delete = fields.Boolean( + 'Auto Delete', default=True, + help="This option permanently removes any track of email after send, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.") + # contextual action + ref_ir_act_window = fields.Many2one('ir.actions.act_window', 'Sidebar action', readonly=True, copy=False, + help="Sidebar action to make this template available on records " + "of the related document model") def unlink(self): self.unlink_action() @@ -178,102 +103,9 @@ class MailTemplate(models.Model): return True - # ---------------------------------------- - # RENDERING - # ---------------------------------------- - - @api.model - def render_post_process(self, html): - html = self.env['mail.thread']._replace_local_links(html) - return html - - @api.model - def _render_template(self, template_txt, model, res_ids, post_process=False): - """ Render the given template text, replace mako expressions ``${expr}`` - with the result of evaluating these expressions with an evaluation - context containing: - - - ``user``: Model of the current user - - ``object``: record of the document record this mail is related to - - ``context``: the context passed to the mail composition wizard - - :param str template_txt: the template text to render - :param str model: model name of the document record this mail is related to. - :param int res_ids: list of ids of document records those mails are related to. - """ - multi_mode = True - if isinstance(res_ids, int): - multi_mode = False - res_ids = [res_ids] - - results = dict.fromkeys(res_ids, u"") - - # try to load the template - try: - mako_env = mako_safe_template_env if self.env.context.get('safe') else mako_template_env - template = mako_env.from_string(tools.ustr(template_txt)) - except Exception: - _logger.info("Failed to load template %r", template_txt, exc_info=True) - return multi_mode and results or results[res_ids[0]] - - # prepare template variables - records = self.env[model].browse(it for it in res_ids if it) # filter to avoid browsing [None] - res_to_rec = dict.fromkeys(res_ids, None) - for record in records: - res_to_rec[record.id] = record - variables = { - 'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code), - 'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code), - 'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code), - 'format_duration': lambda value: tools.format_duration(value), - 'user': self.env.user, - 'ctx': self._context, # context kw would clash with mako internals - } - for res_id, record in res_to_rec.items(): - variables['object'] = record - try: - render_result = template.render(variables) - except Exception as e: - _logger.info("Failed to render template : %s" % e, exc_info=True) - raise UserError(_("Failed to render template : %s") % e) - if render_result == u"False": - render_result = u"" - results[res_id] = render_result - - if post_process: - for res_id, result in results.items(): - results[res_id] = self.render_post_process(result) - - return multi_mode and results or results[res_ids[0]] - - def get_email_template(self, res_ids): - multi_mode = True - if isinstance(res_ids, int): - res_ids = [res_ids] - multi_mode = False - - if res_ids is None: - res_ids = [None] - results = dict.fromkeys(res_ids, False) - - if not self.ids: - return results - self.ensure_one() - - if self.env.context.get('template_preview_lang'): - lang = self.env.context.get('template_preview_lang') - for res_id in res_ids: - results[res_id] = self.with_context(lang=lang) - else: - langs = self._render_template(self.lang, self.model, res_ids) - for res_id, lang in langs.items(): - if lang: - template = self.with_context(lang=lang) - else: - template = self - results[res_id] = template - - return multi_mode and results or results[res_ids[0]] + # ------------------------------------------------------------ + # MESSAGE/EMAIL VALUES GENERATION + # ------------------------------------------------------------ def generate_recipients(self, results, res_ids): """Generates the recipients of the template. Default values can ben generated @@ -302,8 +134,8 @@ class MailTemplate(models.Model): if records_company: Partner = Partner.with_context(default_company_id=records_company[res_id]) for mail in mails: - partner_id = Partner.find_or_create(mail) - partner_ids.append(partner_id) + partner = Partner.find_or_create(mail) + partner_ids.append(partner.id) partner_to = values.pop('partner_to', '') if partner_to: # placeholders could generate '', 3, 2 due to some empty field values @@ -328,24 +160,14 @@ class MailTemplate(models.Model): res_ids = [res_ids] multi_mode = False - res_ids_to_templates = self.get_email_template(res_ids) - - # templates: res_id -> template; template -> res_ids - templates_to_res_ids = {} - for res_id, template in res_ids_to_templates.items(): - templates_to_res_ids.setdefault(template, []).append(res_id) - results = dict() - for template, template_res_ids in templates_to_res_ids.items(): - Template = self.env['mail.template'] - # generate fields value for all res_ids linked to the current template - if template.lang: - Template = Template.with_context(lang=template._context.get('lang')) + for lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items(): for field in fields: - Template = Template.with_context(safe=field in {'subject'}) - generated_field_values = Template._render_template( - getattr(template, field), template.model, template_res_ids, - post_process=(field == 'body_html')) + template = template.with_context(safe=(field == 'subject')) + generated_field_values = template._render_field( + field, template_res_ids, + post_process=(field == 'body_html') + ) for res_id, field_value in generated_field_values.items(): results.setdefault(res_id, dict())[field] = field_value # compute recipients @@ -369,7 +191,7 @@ class MailTemplate(models.Model): if template.report_template: for res_id in template_res_ids: attachments = [] - report_name = self._render_template(template.report_name, template.model, res_id) + report_name = self._render_field('report_name', [res_id])[res_id] report = template.report_template report_service = report.report_name @@ -393,9 +215,9 @@ class MailTemplate(models.Model): return multi_mode and results or results[res_ids[0]] - # ---------------------------------------- + # ------------------------------------------------------------ # EMAIL - # ---------------------------------------- + # ------------------------------------------------------------ def _send_check_access(self, res_ids): records = self.env[self.model].browse(res_ids) @@ -446,7 +268,7 @@ class MailTemplate(models.Model): 'record': record, } body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True) - values['body_html'] = self.env['mail.thread']._replace_local_links(body) + values['body_html'] = self.env['mail.render.mixin']._replace_local_links(body) mail = self.env['mail.mail'].sudo().create(values) # manage attachments diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 96a25fef64e..43d705b1c79 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -476,38 +476,6 @@ class MailThread(models.AbstractModel): return self.with_context(lang=self.env.user.lang) return self - def _replace_local_links(self, html, base_url=None): - """ Replace local links by absolute links. It is required in various - cases, for example when sending emails on chatter or sending mass - mailings. It replaces - - * href of links (mailto will not match the regex) - * src of images (base64 hardcoded data will not match the regex) - * styling using url like background-image: url - - It is done using regex because it is shorten than using an html parser - to create a potentially complex soupe and hope to have a result that - has not been harmed. - """ - if not html: - return html - - html = ustr(html) - - def _sub_relative2absolute(match): - # compute here to do it only if really necessary + cache will ensure it is done only once - # if not base_url - if not _sub_relative2absolute.base_url: - _sub_relative2absolute.base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") - return match.group(1) + urls.url_join(_sub_relative2absolute.base_url, match.group(2)) - - _sub_relative2absolute.base_url = base_url - html = re.sub(r"""(]*\ssrc=")(/[^/][^"]+)""", _sub_relative2absolute, html) - html = re.sub(r"""(]*\shref=")(/[^/][^"]+)""", _sub_relative2absolute, html) - html = re.sub(r"""(<[^>]+\bstyle="[^"]+\burl\('?)(/[^/'][^'")]+)""", _sub_relative2absolute, html) - - return html - @api.model def _garbage_collect_attachments(self): """ Garbage collect lost mail attachments. Those are attachments @@ -2322,7 +2290,7 @@ class MailThread(models.AbstractModel): mail_body = base_template.render(render_values, engine='ir.qweb', minimal_qcontext=True) else: mail_body = message.body - mail_body = self._replace_local_links(mail_body) + mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body) # create email for recipients_ids_chunk in split_every(recipients_max, recipients_ids): @@ -2421,11 +2389,12 @@ class MailThread(models.AbstractModel): # Retrieve the language in which the template was rendered, in order to render the custom # layout in the same language. + # TDE FIXME: this whole brol should be cleaned ! lang = self.env.context.get('lang') if {'default_template_id', 'default_model', 'default_res_id'} <= self.env.context.keys(): template = self.env['mail.template'].browse(self.env.context['default_template_id']) if template and template.lang: - lang = template._render_template(template.lang, self.env.context['default_model'], self.env.context['default_res_id']) + lang = template._render_lang([self.env.context['default_res_id']])[self.env.context['default_res_id']] if not model_description and model: model_description = self.env['ir.model'].with_context(lang=lang)._get(model).display_name @@ -2898,7 +2867,7 @@ class MailThread(models.AbstractModel): 'access_link': self._notify_get_action_link('view'), } assignation_msg = view.render(values, engine='ir.qweb', minimal_qcontext=True) - assignation_msg = self.env['mail.thread']._replace_local_links(assignation_msg) + assignation_msg = self.env['mail.render.mixin']._replace_local_links(assignation_msg) record.message_notify( subject=_('You have been assigned to %s') % record.display_name, body=assignation_msg, diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 70890cb368f..97305962691 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -3,7 +3,7 @@ import logging -from odoo import _, api, fields, models +from odoo import _, api, fields, models, tools from odoo.addons.bus.models.bus_presence import AWAY_TIMER from odoo.addons.bus.models.bus_presence import DISCONNECTION_TIMER from odoo.osv import expression @@ -35,6 +35,22 @@ class Partner(models.Model): 'email_cc': False} for r in self} + @api.model + def find_or_create(self, email, assert_valid_email=False): + """ Override to use the email_normalized field. """ + if not email: + raise ValueError(_('An email is required for find_or_create to work')) + + parsed_name, parsed_email = self._parse_partner_name(email) + if parsed_email: + email_normalized = tools.email_normalize(parsed_email) + if email_normalized: + partners = self.search([('email_normalized', '=', email_normalized)], limit=1) + if partners: + return partners + + return super(Partner, self).find_or_create(email, assert_valid_email=assert_valid_email) + @api.model def get_needaction_count(self): """ compute the number of needaction of the current user """ diff --git a/addons/mail/tests/__init__.py b/addons/mail/tests/__init__.py index aa9c3f5d9f0..4706daea1c6 100644 --- a/addons/mail/tests/__init__.py +++ b/addons/mail/tests/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import common +from . import test_res_partner diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py new file mode 100644 index 00000000000..de635930512 --- /dev/null +++ b/addons/mail/tests/test_res_partner.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase + + +class TestPartner(TransactionCase): + + def test_res_partner_find_or_create(self): + Partner = self.env['res.partner'] + + existing = Partner.create({ + 'name': 'Patrick Poilvache', + 'email': '"Patrick Da Beast Poilvache" ', + }) + self.assertEqual(existing.name, 'Patrick Poilvache') + self.assertEqual(existing.email, '"Patrick Da Beast Poilvache" ') + self.assertEqual(existing.email_normalized, 'patrick@example.com') + + new = Partner.find_or_create('Patrick Caché ') + self.assertEqual(new, existing) + + new2 = Partner.find_or_create('Patrick Caché <2patrick@EXAMPLE.COM>') + self.assertTrue(new2.id > new.id) + self.assertEqual(new2.name, 'Patrick Caché') + self.assertEqual(new2.email, '2patrick@example.com') + self.assertEqual(new2.email_normalized, '2patrick@example.com') diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index fdc7b23508b..6c2e83baaec 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -455,10 +455,10 @@ class MailComposer(models.TransientModel): multi_mode = False res_ids = [res_ids] - subjects = self.env['mail.template']._render_template(self.subject, self.model, res_ids) - bodies = self.env['mail.template']._render_template(self.body, self.model, res_ids, post_process=True) - emails_from = self.env['mail.template']._render_template(self.email_from, self.model, res_ids) - replies_to = self.env['mail.template']._render_template(self.reply_to, self.model, res_ids) + subjects = self.env['mail.render.mixin']._render_template(self.subject, self.model, res_ids) + bodies = self.env['mail.render.mixin']._render_template(self.body, self.model, res_ids, post_process=True) + emails_from = self.env['mail.render.mixin']._render_template(self.email_from, self.model, res_ids) + replies_to = self.env['mail.render.mixin']._render_template(self.reply_to, self.model, res_ids) default_recipients = {} if not self.partner_ids: records = self.env[self.model].browse(res_ids).sudo() diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py index 64b451ac37b..035ce2d73cc 100644 --- a/addons/mass_mailing/models/__init__.py +++ b/addons/mass_mailing/models/__init__.py @@ -7,7 +7,7 @@ from . import mailing_list from . import mailing_trace from . import mailing from . import mail_mail -from . import mail_template +from . import mail_render_mixin from . import mail_thread from . import res_config_settings from . import res_users diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py index 749c6dc6133..d35ae72f4be 100644 --- a/addons/mass_mailing/models/mail_mail.py +++ b/addons/mass_mailing/models/mail_mail.py @@ -6,8 +6,6 @@ import werkzeug.urls from odoo import api, fields, models, tools -from odoo.addons.link_tracker.models.link_tracker import URL_REGEX - class MailMail(models.Model): """Add the mass mailing campaign data to mail""" @@ -54,7 +52,7 @@ class MailMail(models.Model): body = super(MailMail, self)._send_prepare_body() if self.mailing_id and body and self.mailing_trace_ids: - for match in re.findall(URL_REGEX, self.body_html): + for match in re.findall(tools.URL_REGEX, self.body_html): href = match[0] url = match[1] @@ -69,7 +67,7 @@ class MailMail(models.Model): if tracking_url: body = tools.append_content_to_html(body, tracking_url, plaintext=False, container_tag='div') - body = self.env['mail.thread']._replace_local_links(body) + body = self.env['mail.render.mixin']._replace_local_links(body) return body diff --git a/addons/mass_mailing/models/mail_render_mixin.py b/addons/mass_mailing/models/mail_render_mixin.py new file mode 100644 index 00000000000..4447f3931c8 --- /dev/null +++ b/addons/mass_mailing/models/mail_render_mixin.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class MailRenderMixin(models.AbstractModel): + _inherit = "mail.render.mixin" + + @api.model + def _render_template_postprocess(self, rendered): + # super will transform relative url to absolute + rendered = super(MailRenderMixin, self)._render_template_postprocess(rendered) + + # apply shortener after + if self.env.context.get('post_convert_links'): + for res_id, html in rendered.items(): + rendered[res_id] = self._shorten_links( + html, + self.env.context['post_convert_links'], + blacklist=['/unsubscribe_from_list'] + ) + return rendered diff --git a/addons/mass_mailing/models/mail_template.py b/addons/mass_mailing/models/mail_template.py deleted file mode 100644 index 68005ee0d10..00000000000 --- a/addons/mass_mailing/models/mail_template.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, models - - -class MailTemplate(models.Model): - _inherit = "mail.template" - - @api.model - def render_post_process(self, html): - # super will transform relative url to absolute - html = super(MailTemplate, self).render_post_process(html) - - # apply shortener after - if self.env.context.get('post_convert_links'): - html = self.env['link.tracker'].convert_links( - html, - self.env.context['post_convert_links'], - blacklist=['/unsubscribe_from_list'] - ) - return html diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py index 88c5a9c4574..1ce47e6cfa4 100644 --- a/addons/mass_mailing/models/mailing.py +++ b/addons/mass_mailing/models/mailing.py @@ -561,7 +561,7 @@ class MassMailing(models.Model): if mass_mailing.medium_id: vals['medium_id'] = mass_mailing.medium_id.id - res[mass_mailing.id] = self.env['link.tracker'].convert_links(html, vals, blacklist=['/unsubscribe_from_list']) + res[mass_mailing.id] = self._shorten_links(html, vals, blacklist=['/unsubscribe_from_list']) return res diff --git a/addons/mass_mailing/wizard/mailing_mailing_test.py b/addons/mass_mailing/wizard/mailing_mailing_test.py index dab0f70f725..45de0d88019 100644 --- a/addons/mass_mailing/wizard/mailing_mailing_test.py +++ b/addons/mass_mailing/wizard/mailing_mailing_test.py @@ -24,7 +24,7 @@ class TestMassMailing(models.TransientModel): mass_mail_layout = self.env.ref('mass_mailing.mass_mailing_mail_layout') for test_mail in test_emails: # Convert links in absolute URLs before the application of the shortener - body = self.env['mail.thread']._replace_local_links(mailing.body_html) + body = self.env['mail.render.mixin']._replace_local_links(mailing.body_html) body = tools.html_sanitize(body, sanitize_attributes=True, sanitize_style=True) mail_values = { 'email_from': mailing.email_from, diff --git a/addons/mass_mailing_sms/models/sms_sms.py b/addons/mass_mailing_sms/models/sms_sms.py index d83359aa47a..c9c14e144cd 100644 --- a/addons/mass_mailing_sms/models/sms_sms.py +++ b/addons/mass_mailing_sms/models/sms_sms.py @@ -3,9 +3,7 @@ import re -from odoo import fields, models - -TEXT_URL_REGEX = r'https?://[a-zA-Z0-9@:%._+~#=/-]+' +from odoo import fields, models, tools class SmsSms(models.Model): @@ -25,7 +23,7 @@ class SmsSms(models.Model): continue body = sms.body - for url in re.findall(TEXT_URL_REGEX, body): + for url in re.findall(tools.TEXT_URL_REGEX, body): if url.startswith(shortened_schema): body = body.replace(url, url + '/s/%s' % sms.id) res[sms.id] = body diff --git a/addons/mass_mailing_sms/wizard/sms_composer.py b/addons/mass_mailing_sms/wizard/sms_composer.py index fefbeeb0137..b3e50234036 100644 --- a/addons/mass_mailing_sms/wizard/sms_composer.py +++ b/addons/mass_mailing_sms/wizard/sms_composer.py @@ -70,7 +70,7 @@ class SMSComposer(models.TransientModel): if self.mailing_id: tracker_values = self.mailing_id._get_link_tracker_values() for sms_id, body in all_bodies.items(): - body = self.env['link.tracker'].sudo()._convert_links_text(body, tracker_values) + body = self.env['mail.render.mixin'].sudo()._shorten_links_text(body, tracker_values) all_bodies[sms_id] = body return all_bodies diff --git a/addons/payment_paypal/models/payment.py b/addons/payment_paypal/models/payment.py index 38fdb2422ce..f3b99925c85 100644 --- a/addons/payment_paypal/models/payment.py +++ b/addons/payment_paypal/models/payment.py @@ -195,7 +195,7 @@ class TxPaypal(models.Model): render_template = template.render({ 'acquirer': self.acquirer_id, }, engine='ir.qweb') - mail_body = self.env['mail.thread']._replace_local_links(render_template) + mail_body = self.env['mail.render.mixin']._replace_local_links(render_template) mail_values = { 'body_html': mail_body, 'subject': _('Add your Paypal account to Odoo'), diff --git a/addons/portal/__init__.py b/addons/portal/__init__.py index d1a0c30eafd..72caddbddb3 100644 --- a/addons/portal/__init__.py +++ b/addons/portal/__init__.py @@ -3,14 +3,14 @@ # Updating mako environement in order to be able to use slug try: - from odoo.addons.mail.models.mail_template import mako_template_env, mako_safe_template_env + from odoo.addons.mail.models.mail_render_mixin import jinja_template_env, jinja_safe_template_env from odoo.addons.http_routing.models.ir_http import slug - mako_template_env.globals.update({ + jinja_template_env.globals.update({ 'slug': slug, }) - mako_safe_template_env.globals.update({ + jinja_safe_template_env.globals.update({ 'slug': slug, }) except ImportError: diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index aca1186d7ae..3fe6f3389f6 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -292,7 +292,7 @@ class PurchaseOrder(models.Model): if {'default_template_id', 'default_model', 'default_res_id'} <= ctx.keys(): template = self.env['mail.template'].browse(ctx['default_template_id']) if template and template.lang: - lang = template._render_template(template.lang, ctx['default_model'], ctx['default_res_id']) + lang = template._render_lang([ctx['default_res_id']])[ctx['default_res_id']] self = self.with_context(lang=lang) if self.state in ['draft', 'sent']: diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index ca0b98ef8e0..0e749c76e75 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -751,7 +751,7 @@ Reason(s) of this behavior could be: lang = self.env.context.get('lang') template = self.env['mail.template'].browse(template_id) if template.lang: - lang = template._render_template(template.lang, 'sale.order', self.ids[0]) + lang = template._render_lang(self.ids)[self.id] ctx = { 'default_model': 'sale.order', 'default_res_id': self.ids[0], diff --git a/addons/sms/models/mail_thread.py b/addons/sms/models/mail_thread.py index 86713fddc24..820c5230cfe 100644 --- a/addons/sms/models/mail_thread.py +++ b/addons/sms/models/mail_thread.py @@ -180,8 +180,7 @@ class MailThread(models.AbstractModel): if not template and template_xmlid: template = self.env.ref(template_xmlid, raise_if_not_found=False) if template: - template_w_lang = template._get_context_lang_per_id(self.ids)[self.id] - body = template._render_template(template_w_lang.body, self._name, self.ids)[self.id] + body = template._render_field('body', self.ids, compute_lang=True)[self.id] else: body = self.env['sms.template']._render_template(template_fallback, self._name, self.ids)[self.id] return self._message_sms(body, partner_ids=partner_ids, **kwargs) diff --git a/addons/sms/models/sms_template.py b/addons/sms/models/sms_template.py index d09dd26a703..fb5b8877e87 100644 --- a/addons/sms/models/sms_template.py +++ b/addons/sms/models/sms_template.py @@ -24,9 +24,6 @@ class SMSTemplate(models.Model): help="The type of document this template can be used with", ondelete='cascade') model = fields.Char('Related Document Model', related='model_id.model', index=True, store=True, readonly=True) body = fields.Char('Body', translate=True, required=True) - lang = fields.Char('Language', help="Use this field to either force a specific language (ISO code) or dynamically " - "detect the language of your recipient by a placeholder expression " - "(e.g. ${object.partner_id.lang})") # Use to create contextual action (same as for email template) sidebar_action_id = fields.Many2one('ir.actions.act_window', 'Sidebar action', readonly=True, copy=False, help="Sidebar action to make this template available on records " @@ -38,6 +35,10 @@ class SMSTemplate(models.Model): name=_("%s (copy)") % self.name) return super(SMSTemplate, self).copy(default=default) + def unlink(self): + self.sudo().mapped('sidebar_action_id').unlink() + return super(SMSTemplate, self).unlink() + def action_create_sidebar_action(self): ActWindow = self.env['ir.actions.act_window'] view = self.env.ref('sms.sms_composer_view_form') @@ -63,47 +64,3 @@ class SMSTemplate(models.Model): if template.sidebar_action_id: template.sidebar_action_id.unlink() return True - - def _get_context_lang_per_id(self, res_ids): - self.ensure_one() - if res_ids is None: - return {None: self} - - if self.env.context.get('template_preview_lang'): - lang = self.env.context.get('template_preview_lang') - results = dict((res_id, self.with_context(lang=lang)) for res_id in res_ids) - else: - rendered_langs = self._render_template(self.lang, self.model, res_ids) - results = dict((res_id, self.with_context(lang=lang) if lang else self) - for res_id, lang in rendered_langs.items()) - - return results - - def _get_ids_per_lang(self, res_ids): - self.ensure_one() - - rids_to_tpl = self._get_context_lang_per_id(res_ids) - tpl_to_rids = {} - for res_id, template in rids_to_tpl.items(): - tpl_to_rids.setdefault(template._context.get('lang', self.env.user.lang), []).append(res_id) - - return tpl_to_rids - - def _get_translated_bodies(self, res_ids): - """ return sms translated bodies into a dict {'res_id':'body'} """ - self.ensure_one() - lang_to_rids = self._get_ids_per_lang(res_ids) - all_bodies = {} - for lang, rids in lang_to_rids.items(): - template = self.with_context(lang=lang) - all_bodies.update(template._render_template(template.body, self.model, rids)) - return all_bodies - - @api.model - def _render_template(self, template_txt, model, res_ids): - """ Render the jinja template """ - return self.env['mail.template']._render_template(template_txt, model, res_ids) - - def unlink(self): - self.sudo().mapped('sidebar_action_id').unlink() - return super(SMSTemplate, self).unlink() diff --git a/addons/sms/wizard/sms_composer.py b/addons/sms/wizard/sms_composer.py index df76d2e9c60..7d5cbc9bfd2 100644 --- a/addons/sms/wizard/sms_composer.py +++ b/addons/sms/wizard/sms_composer.py @@ -168,7 +168,7 @@ class SendSMS(models.TransientModel): @api.onchange('composition_mode', 'res_model', 'res_id', 'template_id') def _onchange_template_id(self): if self.template_id and self.composition_mode == 'comment' and self.res_id: - self.body = self.template_id._get_translated_bodies([self.res_id])[self.res_id] + self.body = self.template_id._render_field('body', [self.res_id], compute_lang=True)[self.res_id] elif self.template_id: self.body = self.template_id.body @@ -279,9 +279,9 @@ class SendSMS(models.TransientModel): def _prepare_body_values(self, records): if self.template_id and self.body == self.template_id.body: - all_bodies = self.template_id._get_translated_bodies(records.ids) + all_bodies = self.template_id._render_field('body', records.ids, compute_lang=True) else: - all_bodies = self.env['mail.template']._render_template(self.body, records._name, records.ids) + all_bodies = self.env['mail.render.mixin']._render_template(self.body, records._name, records.ids) return all_bodies def _prepare_mass_sms_values(self, records): diff --git a/addons/sms/wizard/sms_template_preview.py b/addons/sms/wizard/sms_template_preview.py index 9d00185ee2e..ffe22a4238a 100644 --- a/addons/sms/wizard/sms_template_preview.py +++ b/addons/sms/wizard/sms_template_preview.py @@ -29,8 +29,7 @@ class SMSTemplatePreview(models.TransientModel): result['resource_ref'] = '%s,%s' % (sms_template.model_id.model, res.id) return result - sms_template_id = fields.Many2one('sms.template') # NOTE This should probably be required - + sms_template_id = fields.Many2one('sms.template', required=True, ondelete='cascade') lang = fields.Selection(_selection_languages, string='Template Preview Language') model_id = fields.Many2one('ir.model', related="sms_template_id.model_id") body = fields.Char('Body', compute='_compute_sms_template_fields') @@ -45,6 +44,7 @@ class SMSTemplatePreview(models.TransientModel): @api.depends('lang', 'resource_ref') def _compute_sms_template_fields(self): for wizard in self: - # Update body depending of the resource_ref - sms_template = wizard.sms_template_id.with_context(lang=wizard.lang) - wizard.body = sms_template._render_template(sms_template.body, sms_template.model, wizard.resource_ref.id) if wizard.resource_ref else sms_template.body + if wizard.sms_template_id and wizard.resource_ref: + wizard.body = wizard.sms_template_id._render_field('body', [wizard.resource_ref.id], set_lang=wizard.lang)[wizard.resource_ref.id] + else: + wizard.body = wizard.sms_template_id.body diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index 241be409333..7896cddfa0e 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -199,8 +199,8 @@ class SurveyInvite(models.TransientModel): def _send_mail(self, answer): """ Create mail specific for recipient containing notably its access token """ - subject = self.env['mail.template']._render_template(self.subject, 'survey.user_input', answer.id, post_process=True) - body = self.env['mail.template']._render_template(self.body, 'survey.user_input', answer.id, post_process=True) + subject = self.env['mail.render.mixin']._render_template(self.subject, 'survey.user_input', answer.ids, post_process=True)[answer.id] + body = self.env['mail.render.mixin']._render_template(self.body, 'survey.user_input', answer.ids, post_process=True)[answer.id] # post the message mail_values = { 'email_from': self.email_from, @@ -231,7 +231,7 @@ class SurveyInvite(models.TransientModel): 'company': self.env.company, } body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True) - mail_values['body_html'] = self.env['mail.thread']._replace_local_links(body) + mail_values['body_html'] = self.env['mail.render.mixin']._replace_local_links(body) return self.env['mail.mail'].sudo().create(mail_values) diff --git a/addons/test_mail_full/tests/test_sms_sms.py b/addons/test_mail_full/tests/test_sms_sms.py index de7e37ac641..e404eaec572 100644 --- a/addons/test_mail_full/tests/test_sms_sms.py +++ b/addons/test_mail_full/tests/test_sms_sms.py @@ -64,7 +64,7 @@ class TestSMSPost(test_mail_full_common.TestSMSCommon, LinkTrackerMock): def test_body_link_shorten(self): link = 'http://www.example.com' self.env['link.tracker'].search([('url', '=', link)]).unlink() - new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values) + new_body = self.env['mail.render.mixin']._shorten_links_text('Welcome to %s !' % link, self.tracker_values) self.assertNotIn(link, new_body) self.assertLinkTracker(link, {'utm_campaign': self.utm_c.name, 'utm_medium': self.utm_m.name}) link = self.env['link.tracker'].search([('url', '=', link)]) @@ -72,7 +72,7 @@ class TestSMSPost(test_mail_full_common.TestSMSCommon, LinkTrackerMock): link = 'https://test.odoo.com/my/super_page?test[0]=42&toto=áâà#title3' self.env['link.tracker'].search([('url', '=', link)]).unlink() - new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values) + new_body = self.env['mail.render.mixin']._shorten_links_text('Welcome to %s !' % link, self.tracker_values) self.assertNotIn(link, new_body) self.assertLinkTracker(link, { 'utm_campaign': self.utm_c.name, @@ -86,14 +86,14 @@ class TestSMSPost(test_mail_full_common.TestSMSCommon, LinkTrackerMock): def test_body_link_shorten_wshort(self): link = 'https://test.odoo.com/r/RAOUL' self.env['link.tracker'].search([('url', '=', link)]).unlink() - new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values) + new_body = self.env['mail.render.mixin']._shorten_links_text('Welcome to %s !' % link, self.tracker_values) self.assertIn(link, new_body) self.assertFalse(self.env['link.tracker'].search([('url', '=', link)])) def test_body_link_shorten_wunsubscribe(self): link = 'https://test.odoo.com/sms/3/' self.env['link.tracker'].search([('url', '=', link)]).unlink() - new_body = self.env['link.tracker']._convert_links_text('Welcome to %s !' % link, self.tracker_values) + new_body = self.env['mail.render.mixin']._shorten_links_text('Welcome to %s !' % link, self.tracker_values) self.assertIn(link, new_body) self.assertFalse(self.env['link.tracker'].search([('url', '=', link)])) diff --git a/addons/test_mail_full/tests/test_sms_template.py b/addons/test_mail_full/tests/test_sms_template.py index a39edf8545f..d688b30d0f7 100644 --- a/addons/test_mail_full/tests/test_sms_template.py +++ b/addons/test_mail_full/tests/test_sms_template.py @@ -50,18 +50,20 @@ class TestSmsTemplate(test_mail_full_common.TestSMSCommon, test_mail_full_common self.assertEqual(self.sms_template.body, self.body_en) self.assertEqual(self.sms_template.with_context(lang='fr_FR').body, self.body_fr) - rid_to_tpl = self.sms_template._get_context_lang_per_id((self.test_record | test_record_2).ids) - self.assertEqual(set(rid_to_tpl.keys()), set((self.test_record | test_record_2).ids)) - for rid, tpl in rid_to_tpl.items(): + rid_to_lang = self.sms_template._render_lang((self.test_record | test_record_2).ids) + self.assertEqual(set(rid_to_lang.keys()), set((self.test_record | test_record_2).ids)) + for rid, lang in rid_to_lang.items(): + # TDE FIXME: False or en_US ? if rid == self.test_record.id: - self.assertEqual(tpl._context.get('lang'), 'en_US') + self.assertEqual(lang, 'en_US') elif rid == test_record_2.id: - self.assertEqual(tpl._context.get('lang'), 'fr_FR') + self.assertEqual(lang, 'fr_FR') else: self.assertTrue(False) - lang_to_rids = self.sms_template._get_ids_per_lang((self.test_record | test_record_2).ids) - for lang, rids in lang_to_rids.items(): + tpl_to_rids = self.sms_template._classify_per_lang((self.test_record | test_record_2).ids) + for lang, (tpl, rids) in tpl_to_rids.items(): + # TDE FIXME: False or en_US ? if lang == 'en_US': self.assertEqual(rids, self.test_record.ids) elif lang == 'fr_FR': @@ -87,4 +89,3 @@ class TestSmsTemplate(test_mail_full_common.TestSMSCommon, test_mail_full_common self.sms_template.unlink() self.assertEqual(ActWindow.search_count([('id', '=', action_id)]), 0) - diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index ff3ac20bb24..a399b4411f0 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -499,8 +499,8 @@ class Slide(models.Model): base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for slide in self.filtered(lambda slide: slide.website_published and slide.channel_id.publish_template_id): publish_template = slide.channel_id.publish_template_id - html_body = publish_template.with_context(base_url=base_url)._render_template(publish_template.body_html, 'slide.slide', slide.id) - subject = publish_template._render_template(publish_template.subject, 'slide.slide', slide.id) + html_body = publish_template.with_context(base_url=base_url)._render_field('body_html', slide.ids)[slide.id] + subject = publish_template._render_field('subject', slide.ids)[slide.id] slide.channel_id.with_context(mail_create_nosubscribe=True).message_post( subject=subject, body=html_body, diff --git a/addons/website_slides/wizard/slide_channel_invite.py b/addons/website_slides/wizard/slide_channel_invite.py index 43e384ade30..9650b4d2f9e 100644 --- a/addons/website_slides/wizard/slide_channel_invite.py +++ b/addons/website_slides/wizard/slide_channel_invite.py @@ -95,8 +95,8 @@ class SlideChannelInvite(models.TransientModel): def _prepare_mail_values(self, slide_channel_partner): """ Create mail specific for recipient """ - subject = self.env['mail.template']._render_template(self.subject, 'slide.channel.partner', slide_channel_partner.id, post_process=True) - body = self.env['mail.template']._render_template(self.body, 'slide.channel.partner', slide_channel_partner.id, post_process=True) + subject = self.env['mail.render.mixin']._render_template(self.subject, 'slide.channel.partner', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] + body = self.env['mail.render.mixin']._render_template(self.body, 'slide.channel.partner', slide_channel_partner.ids, post_process=True)[slide_channel_partner.id] # post the message mail_values = { 'email_from': self.email_from, @@ -124,6 +124,6 @@ class SlideChannelInvite(models.TransientModel): 'company': self.env.company, } body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True) - mail_values['body_html'] = self.env['mail.thread']._replace_local_links(body) + mail_values['body_html'] = self.env['mail.render.mixin']._replace_local_links(body) return mail_values diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 647afd65820..728bd040f1b 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -647,16 +647,33 @@ class Partner(models.Model): res.append((partner.id, name)) return res - def _parse_partner_name(self, text, context=None): - """ Supported syntax: - - 'Raoul ': will find name and email address - - otherwise: default, everything is set as the name """ - emails = tools.email_split(text.replace(' ', ',')) - if emails: - email = emails[0] - name = text[:text.index(email)].replace('"', '').replace('<', '').strip() + def _parse_partner_name(self, text): + """ Parse partner name (given by text) in order to find a name and an + email. Supported syntax: + + * Raoul + * "Raoul le Grand" + * Raoul raoul@grosbedon.fr (strange fault tolerant support from df40926d2a57c101a3e2d221ecfd08fbb4fea30e) + + Otherwise: default, everything is set as the name. Starting from 13.3 + returned email will be normalized to have a coherent encoding. + """ + name, email = '', '' + split_results = tools.email_split_tuples(text) + if split_results: + name, email = split_results[0] + + if email and not name: + fallback_emails = tools.email_split(text.replace(' ', ',')) + if fallback_emails: + email = fallback_emails[0] + name = text[:text.index(email)].replace('"', '').replace('<', '').strip() + + if email: + email = tools.email_normalize(email) else: name, email = text, '' + return name, email @api.model @@ -670,9 +687,11 @@ class Partner(models.Model): name, email = self._parse_partner_name(name) if self._context.get('force_email') and not email: raise UserError(_("Couldn't create contact without email address!")) - if not name and email: - name = email - partner = self.create({self._rec_name: name or email, 'email': email or self.env.context.get('default_email', False)}) + + create_values = {self._rec_name: name or email} + if email: # keep default_email in context + create_values['email'] = email + partner = self.create(create_values) return partner.name_get()[0] @api.model @@ -752,22 +771,30 @@ class Partner(models.Model): return super(Partner, self)._name_search(name, args, operator=operator, limit=limit, name_get_uid=name_get_uid) @api.model - def find_or_create(self, email): + def find_or_create(self, email, assert_valid_email=False): """ Find a partner with the given ``email`` or use :py:method:`~.name_create` - to create one + to create a new one. - :param str email: email-like string, which should contain at least one email, - e.g. ``"Raoul Grosbedon "``""" - assert email, 'an email is required for find_or_create to work' - emails = tools.email_split(email) - name_emails = tools.email_split_and_format(email) - if emails: - email = emails[0] - name_email = name_emails[0] - else: - name_email = email - partners = self.search([('email', '=ilike', email)], limit=1) - return partners.id or self.name_create(name_email)[0] + :param str email: email-like string, which should contain at least one email, + e.g. ``"Raoul Grosbedon "`` + :param boolean assert_valid_email: raise if no valid email is found + :return: newly created record + """ + if not email: + raise ValueError(_('An email is required for find_or_create to work')) + + parsed_name, parsed_email = self._parse_partner_name(email) + if not parsed_email and assert_valid_email: + raise ValueError(_('A valid email is required for find_or_create to work properly.')) + + partners = self.search([('email', '=ilike', parsed_email)], limit=1) + if partners: + return partners + + create_values = {self._rec_name: parsed_name or parsed_email} + if parsed_email: # keep default_email in context + create_values['email'] = parsed_email + return self.create(create_values) def _get_gravatar_image(self, email): email_hash = hashlib.md5(email.lower().encode('utf-8')).hexdigest() diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py index 1ca22defd42..eef6c749095 100644 --- a/odoo/addons/base/tests/test_base.py +++ b/odoo/addons/base/tests/test_base.py @@ -68,41 +68,89 @@ SAMPLES = [ ('ryu+giga-Sushi@aizubange.fukushima.jp', '', 'ryu+giga-Sushi@aizubange.fukushima.jp'), ('Raoul chirurgiens-dentistes.fr', 'Raoul chirurgiens-dentistes.fr', ''), (" Raoul O'hara ", "Raoul O'hara", '!@historicalsociety.museum'), + ('Raoul Grosbedon ', 'Raoul Grosbedon', 'raoul@CHIRURGIENS-dentistes.fr'), + ('Raoul megaraoul@chirurgiens-dentistes.fr', 'Raoul', 'megaraoul@chirurgiens-dentistes.fr'), ] + class TestBase(TransactionCase): + def _check_find_or_create(self, test_string, expected_name, expected_email, check_partner=False, should_create=False): + partner = self.env['res.partner'].find_or_create(test_string) + if should_create and check_partner: + self.assertTrue(partner.id > check_partner.id, 'find_or_create failed - should have found existing') + elif check_partner: + self.assertEqual(partner, check_partner, 'find_or_create failed - should have found existing') + self.assertEqual(partner.name, expected_name) + self.assertEqual(partner.email or '', expected_email) + return partner + def test_00_res_partner_name_create(self): res_partner = self.env['res.partner'] parse = res_partner._parse_partner_name for text, name, mail in SAMPLES: - self.assertEqual((name, mail), parse(text), 'Partner name parsing failed') + self.assertEqual((name, mail.lower()), parse(text)) partner_id, dummy = res_partner.name_create(text) partner = res_partner.browse(partner_id) - self.assertEqual(name or mail, partner.name, 'Partner name incorrect') - self.assertEqual(mail or False, partner.email, 'Partner email incorrect') + self.assertEqual(name or mail.lower(), partner.name) + self.assertEqual(mail.lower() or False, partner.email) + + # name_create supports default_email fallback + partner = self.env['res.partner'].browse( + self.env['res.partner'].with_context( + default_email='John.Wick@example.com' + ).name_create('"Raoulette Vachette" ')[0] + ) + self.assertEqual(partner.name, 'Raoulette Vachette') + self.assertEqual(partner.email, 'raoul@grosbedon.fr') + + partner = self.env['res.partner'].browse( + self.env['res.partner'].with_context( + default_email='John.Wick@example.com' + ).name_create('Raoulette Vachette')[0] + ) + self.assertEqual(partner.name, 'Raoulette Vachette') + self.assertEqual(partner.email, 'John.Wick@example.com') def test_10_res_partner_find_or_create(self): res_partner = self.env['res.partner'] - email = SAMPLES[0][0] - partner_id, dummy = res_partner.name_create(email) - found_id = res_partner.find_or_create(email) - self.assertEqual(partner_id, found_id, 'find_or_create failed') - self.assertEqual(SAMPLES[0][1], res_partner.browse([found_id]).name, 'Partner name is incorrect') + partner = res_partner.browse(res_partner.name_create(SAMPLES[0][0])[0]) + self._check_find_or_create( + SAMPLES[0][0], SAMPLES[0][1], SAMPLES[0][2], + check_partner=partner, should_create=False + ) - partner_id2, dummy2 = res_partner.name_create('sarah.john@connor.com') - found_id2 = res_partner.find_or_create('john@connor.com') - self.assertNotEqual(partner_id2, found_id2, 'john@connor.com match sarah.john@connor.com') - self.assertEqual('john@connor.com', res_partner.browse([found_id2]).name, 'Partner name is incorrect') + partner_2 = res_partner.browse(res_partner.name_create('sarah.john@connor.com')[0]) + found_2 = self._check_find_or_create( + 'john@connor.com', 'john@connor.com', 'john@connor.com', + check_partner=partner_2, should_create=True + ) - new_id = res_partner.find_or_create(SAMPLES[1][0]) - self.assertTrue(new_id > partner_id, 'find_or_create failed - should have created new one') - self.assertEqual(SAMPLES[1][2], res_partner.browse([new_id]).name, 'Partner name is incorrect') + new = self._check_find_or_create( + SAMPLES[1][0], SAMPLES[1][2].lower(), SAMPLES[1][2].lower(), + check_partner=found_2, should_create=True + ) - new_id2 = res_partner.find_or_create(SAMPLES[2][0]) - self.assertTrue(new_id2 > new_id, 'find_or_create failed - should have created new one again') - self.assertEqual(SAMPLES[2][1], res_partner.browse([new_id2]).name, 'Partner name is incorrect') + new2 = self._check_find_or_create( + SAMPLES[2][0], SAMPLES[2][1], SAMPLES[2][2], + check_partner=new, should_create=True + ) + + new3 = self._check_find_or_create( + SAMPLES[3][0], SAMPLES[3][1], SAMPLES[3][2], + check_partner=new2, should_create=True + ) + + new4 = self._check_find_or_create( + SAMPLES[4][0], SAMPLES[0][1], SAMPLES[0][2], + check_partner=partner, should_create=False + ) + + new5 = self._check_find_or_create( + SAMPLES[5][0], SAMPLES[5][1], SAMPLES[5][2], + check_partner=new4, should_create=True + ) def test_15_res_partner_name_search(self): res_partner = self.env['res.partner'] diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py index 21756fe0640..d1bc961e8ff 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -13,6 +13,7 @@ import time from email.utils import getaddresses from lxml import etree +from werkzeug import urls import odoo from odoo.loglevels import ustr @@ -250,9 +251,19 @@ def html_sanitize(src, silent=True, sanitize_tags=True, sanitize_attributes=Fals return cleaned -#---------------------------------------------------------- +# ---------------------------------------------------------- # HTML/Text management -#---------------------------------------------------------- +# ---------------------------------------------------------- + +URL_REGEX = r'(\bhref=[\'"](?!mailto:|tel:|sms:)([^\'"]+)[\'"])' +TEXT_URL_REGEX = r'https?://[a-zA-Z0-9@:%._\+~#=/-]+(?:\?\S+)?' + + +def validate_url(url): + if urls.url_parse(url).scheme not in ('http', 'https', 'ftp', 'ftps'): + return 'http://' + url + + return url def is_html_empty(html_content): @@ -281,6 +292,7 @@ def html_keep_url(text): final += text[idx:] return final + def html2plaintext(html, body_id=None, encoding='utf-8'): """ From an HTML text, convert the HTML to plain text. If @param body_id is provided then this is the tag where the