[IMP] mail: introduce a batch version of Partner._find_or_create ()
Purpose is to be able to create find or create partners in batch based on a list of emails. When sending email in batch, notably using templates, doing sequentially a search or create for each potential email to contact is not efficient from computation point of view. This commit introduces a new batch method for that purpose. If no valid email is found for a given email, it is used as it to try to find partners with same invalid email and/or keep the value in order to allow updating it afterwards. Notably with notifications resend it is possible to update emails, if only a typo prevents from having a real email for example. Falsy emails do not create partners as those have no real interest. Task-3034875 (Mail: Speedup and batch partners find or create with templates) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#99482
This commit is contained in:
@@ -102,6 +102,97 @@ class Partner(models.Model):
|
||||
create_values['email'] = parsed_email
|
||||
return self.create(create_values)
|
||||
|
||||
@api.model
|
||||
def _find_or_create_from_emails(self, emails, additional_values=None):
|
||||
""" Based on a list of emails, find or create partners. Additional values
|
||||
can be given to newly created partners. If an email is not unique (e.g.
|
||||
multi-email input), only the first found email is considered.
|
||||
|
||||
If no valid email is found for a given item, the given value is used to
|
||||
find partners with same invalid email or create a new one with the wrong
|
||||
value. It allows updating it afterwards. Notably with notifications
|
||||
resend it is possible to update emails, if only a typo prevents from
|
||||
having a real email for example.
|
||||
|
||||
:param list emails: list of emails that may be formatted (each input
|
||||
will be parsed and normalized);
|
||||
:param dict additional_values: additional values given to create if
|
||||
the partner is not found. Typically used to propagate a company_id;
|
||||
|
||||
:return: res.partner records in a list, following order of emails. It
|
||||
is not a recordset, to keep Falsy values.
|
||||
"""
|
||||
partners, tocreate_vals_list = self.env['res.partner'], []
|
||||
name_emails = [self._parse_partner_name(email) for email in emails]
|
||||
|
||||
# find valid emails_normalized, filtering out false / void values, and search
|
||||
# for existing partners based on those emails
|
||||
emails_normalized = {email_normalized
|
||||
for _name, email_normalized in name_emails
|
||||
if email_normalized}
|
||||
# find partners for invalid (but not void) emails, aka either invalid email
|
||||
# either no email and a name that will be used as email
|
||||
names = {
|
||||
name.strip()
|
||||
for name, email_normalized in name_emails
|
||||
if not email_normalized and name.strip()
|
||||
}
|
||||
if emails_normalized or names:
|
||||
domains = []
|
||||
if emails_normalized:
|
||||
domains.append([('email_normalized', 'in', list(emails_normalized))])
|
||||
if names:
|
||||
domains.append([('email', 'in', list(names))])
|
||||
partners += self.search(expression.OR(domains))
|
||||
|
||||
# create partners for valid email without any existing partner. Keep
|
||||
# only first found occurrence of each normalized email, aka: ('Norbert',
|
||||
# 'norbert@gmail.com'), ('Norbert With Surname', 'norbert@gmail.com')'
|
||||
# -> a single partner is created for email 'norbert@gmail.com'
|
||||
seen = set()
|
||||
notfound_emails = (emails_normalized - set(partners.mapped('email_normalized'))) if partners else emails_normalized
|
||||
notfound_name_emails = [
|
||||
name_email
|
||||
for name_email in name_emails
|
||||
if name_email[1] in notfound_emails and name_email[1] not in seen
|
||||
and not seen.add(name_email[1])
|
||||
]
|
||||
tocreate_vals_list += [
|
||||
{
|
||||
self._rec_name: name or email_normalized,
|
||||
'email': email_normalized,
|
||||
**(additional_values or {})
|
||||
}
|
||||
for name, email_normalized in notfound_name_emails
|
||||
]
|
||||
|
||||
# create partners for invalid emails (aka name and not email_normalized)
|
||||
# without any existing partner
|
||||
tocreate_vals_list += [
|
||||
{
|
||||
self._rec_name: name,
|
||||
'email': name,
|
||||
**(additional_values or {})
|
||||
}
|
||||
for name in names if name not in partners.mapped('email')
|
||||
]
|
||||
|
||||
# create partners once
|
||||
if tocreate_vals_list:
|
||||
partners += self.create(tocreate_vals_list)
|
||||
|
||||
return [
|
||||
next(
|
||||
(partner for partner in partners
|
||||
if (email_normalized and partner.email_normalized == email_normalized)
|
||||
or (not email_normalized and email and partner.email == email)
|
||||
or (not email_normalized and name and partner.name == name)
|
||||
),
|
||||
self.env['res.partner']
|
||||
)
|
||||
for (name, email_normalized), email in zip(name_emails, emails)
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# DISCUSS
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from odoo import tools
|
||||
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
|
||||
from odoo.tests.common import Form, users
|
||||
|
||||
from odoo.tests.common import Form, tagged, users
|
||||
from odoo.addons.base.models.res_partner import Partner
|
||||
|
||||
# samples use effective TLDs from the Mozilla public suffix
|
||||
# list at http://publicsuffix.org
|
||||
SAMPLES = [
|
||||
('"Raoul Grosbedon" <raoul@chirurgiens-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
|
||||
('ryu+giga-Sushi@aizubange.fukushima.jp', '', 'ryu+giga-Sushi@aizubange.fukushima.jp'),
|
||||
('ryu+giga-Sushi@aizubange.fukushima.jp', 'ryu+giga-sushi@aizubange.fukushima.jp', 'ryu+giga-Sushi@aizubange.fukushima.jp'),
|
||||
('Raoul chirurgiens-dentistes.fr', 'Raoul chirurgiens-dentistes.fr', ''),
|
||||
(" Raoul O'hara <!@historicalsociety.museum>", "Raoul O'hara", '!@historicalsociety.museum'),
|
||||
('Raoul Grosbedon <raoul@CHIRURGIENS-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@CHIRURGIENS-dentistes.fr'),
|
||||
('Raoul megaraoul@chirurgiens-dentistes.fr', 'Raoul', 'megaraoul@chirurgiens-dentistes.fr'),
|
||||
('"Patrick Da Beast Poilvache" <PATRICK@example.com>', 'Patrick Poilvache', 'patrick@example.com'),
|
||||
('Patrick Caché <patrick@EXAMPLE.COM>', 'Patrick Poilvache', 'patrick@example.com'),
|
||||
('Patrick Caché <2patrick@EXAMPLE.COM>', 'Patrick Caché', '2patrick@example.com'),
|
||||
|
||||
('"Patrick Da Beast Poilvache" <PATRICK@example.com>', 'Patrick Da Beast Poilvache', 'patrick@example.com'),
|
||||
('Patrick Caché <patrick@EXAMPLE.COM>', 'Patrick Da Beast Poilvache', 'patrick@example.com'),
|
||||
('Patrick Caché <patrick.2@EXAMPLE.COM>', 'Patrick Caché', 'patrick.2@example.com'),
|
||||
]
|
||||
|
||||
|
||||
@tagged('res_partner')
|
||||
class TestPartner(MailCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._activate_multi_company()
|
||||
|
||||
@contextmanager
|
||||
def mockPartnerCalls(self):
|
||||
_original_create = Partner.create
|
||||
_original_search = Partner.search
|
||||
self._new_partners = self.env['res.partner']
|
||||
|
||||
def _res_partner_create(model, *args, **kwargs):
|
||||
records = _original_create(model, *args, **kwargs)
|
||||
self._new_partners += records.sudo()
|
||||
return records
|
||||
|
||||
with patch.object(Partner, 'create',
|
||||
autospec=True, side_effect=_res_partner_create) as mock_partner_create, \
|
||||
patch.object(Partner, 'search',
|
||||
autospec=True, side_effect=_original_search) as mock_partner_search:
|
||||
self._mock_partner_create = mock_partner_create
|
||||
self._mock_partner_search = mock_partner_search
|
||||
yield
|
||||
|
||||
def _check_find_or_create(self, test_string, expected_name, expected_email, expected_email_normalized=False, check_partner=False, should_create=False):
|
||||
expected_email_normalized = expected_email_normalized or expected_email
|
||||
partner = self.env['res.partner'].find_or_create(test_string)
|
||||
@@ -36,16 +64,42 @@ class TestPartner(MailCommon):
|
||||
self.assertEqual(partner.email_normalized or '', expected_email_normalized)
|
||||
return partner
|
||||
|
||||
def test_res_partner_find_or_create(self):
|
||||
Partner = self.env['res.partner']
|
||||
def test_parse_partner_name(self):
|
||||
samples = [
|
||||
'Raoul raoul@grosbedon.fr',
|
||||
'Raoul chirurgiens-dentistes.fr',
|
||||
'invalid',
|
||||
'False',
|
||||
'',
|
||||
' ',
|
||||
False,
|
||||
None,
|
||||
]
|
||||
expected = [
|
||||
('Raoul', 'raoul@grosbedon.fr'),
|
||||
('Raoul chirurgiens-dentistes.fr', ''),
|
||||
('invalid', ''),
|
||||
('False', ''),
|
||||
('', ''),
|
||||
('', ''),
|
||||
('', ''),
|
||||
('', ''),
|
||||
]
|
||||
for (expected_name, expected_email), sample in zip(expected, samples):
|
||||
parsed = self.env['res.partner']._parse_partner_name(sample)
|
||||
self.assertEqual(parsed[0], expected_name)
|
||||
self.assertEqual(parsed[1], expected_email)
|
||||
|
||||
partner = Partner.browse(Partner.name_create(SAMPLES[0][0])[0])
|
||||
def test_res_partner_find_or_create(self):
|
||||
PartnerModel = self.env['res.partner']
|
||||
|
||||
partner = PartnerModel.browse(PartnerModel.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_2 = Partner.browse(Partner.name_create('sarah.john@connor.com')[0])
|
||||
partner_2 = PartnerModel.browse(PartnerModel.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
|
||||
@@ -76,7 +130,7 @@ class TestPartner(MailCommon):
|
||||
check_partner=new4, should_create=True
|
||||
)
|
||||
|
||||
existing = Partner.create({
|
||||
existing = PartnerModel.create({
|
||||
'name': SAMPLES[6][1],
|
||||
'email': SAMPLES[6][0],
|
||||
})
|
||||
@@ -98,6 +152,158 @@ class TestPartner(MailCommon):
|
||||
with self.assertRaises(ValueError):
|
||||
self.env['res.partner'].find_or_create("Raoul chirurgiens-dentistes.fr", assert_valid_email=True)
|
||||
|
||||
@users('employee_c2')
|
||||
def test_res_partner_find_or_create_from_emails(self):
|
||||
""" Test for _find_or_create_from_emails allowing to find or create
|
||||
partner based on emails in a batch-enabled and optimized fashion. """
|
||||
self.user_employee_c2.write({
|
||||
'groups_id': [(4, self.env.ref('base.group_partner_manager').id)],
|
||||
})
|
||||
|
||||
samples_emails = [item[0] for item in SAMPLES]
|
||||
partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
|
||||
samples_emails,
|
||||
additional_values=None,
|
||||
)
|
||||
self.assertEqual(len(partners), len(SAMPLES))
|
||||
for (sample, exp_name, exp_email), partner in zip(SAMPLES, partners):
|
||||
exp_email_normalized = tools.email_normalize(exp_email)
|
||||
self.assertFalse(partner.company_id)
|
||||
self.assertEqual(partner.email_normalized, exp_email_normalized)
|
||||
self.assertTrue(partner.id)
|
||||
self.assertEqual(partner.lang, 'en_US')
|
||||
self.assertEqual(partner.name, exp_name)
|
||||
|
||||
new_samples = SAMPLES + [
|
||||
('"New Customer" <new.customer@test.EXAMPLE.com>', 'New Customer', 'new.customer@test.example.com'),
|
||||
('"Duplicated Raoul" <RAOUL@chirurgiens-dentistes.fr>', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'),
|
||||
('Invalid', 'Invalid', ''),
|
||||
(False, False, False),
|
||||
(None, False, False),
|
||||
(' ', False, False),
|
||||
('', False, False),
|
||||
]
|
||||
samples_emails = [item[0] for item in new_samples]
|
||||
partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
|
||||
samples_emails,
|
||||
additional_values={'company_id': self.env.company.id},
|
||||
)
|
||||
self.assertEqual(len(partners), len(new_samples))
|
||||
for (sample, exp_name, exp_email), partner in zip(new_samples, partners):
|
||||
with self.subTest(sample=sample, exp_name=exp_name, exp_email=exp_email, partner=partner):
|
||||
exp_company = self.env.company if sample in [
|
||||
'"New Customer" <new.customer@test.EXAMPLE.com>', # valid email, not known -> new customer
|
||||
'Invalid' # invalid email, not known -> create a new partner
|
||||
] else self.env['res.company']
|
||||
if sample in [False, None, ' ', '']:
|
||||
self.assertFalse(partner)
|
||||
else:
|
||||
exp_email_normalized = tools.email_normalize(exp_email)
|
||||
self.assertEqual(partner.company_id, exp_company)
|
||||
self.assertEqual(partner.email_normalized, exp_email_normalized)
|
||||
self.assertEqual(partner.name, exp_name)
|
||||
|
||||
@users('employee_c2')
|
||||
def test_res_partner_find_or_create_from_emails_dupes(self):
|
||||
""" Specific test for duplicates management: based on email to avoid
|
||||
creating similar partners. """
|
||||
self.user_employee_c2.write({
|
||||
'groups_id': [(4, self.env.ref('base.group_partner_manager').id)],
|
||||
})
|
||||
|
||||
# all same partner, same email 'test.customer@test.dupe.example.com'
|
||||
email_dupes_samples = [
|
||||
'"Formatted Customer" <test.customer@TEST.DUPE.EXAMPLE.COM>',
|
||||
'test.customer@test.dupe.example.com',
|
||||
'"Another Name" <test.customer@TEST.DUPE.EXAMPLE.COM>',
|
||||
'"Mix of both" <test.customer@test.dupe.EXAMPLE.COM',
|
||||
]
|
||||
email_expected_name = "Formatted Customer" # first found email will setup partner info
|
||||
email_expected_email = 'test.customer@test.dupe.example.com' # normalized version of given email
|
||||
# all same partner, same invalid email 'test.customer.invalid.email'
|
||||
name_dupes_samples = [
|
||||
'test.customer.invalid.email',
|
||||
'test.customer.invalid.email',
|
||||
]
|
||||
name_expected_name = 'test.customer.invalid.email' # invalid email kept as both name and email
|
||||
name_expected_email = 'test.customer.invalid.email' # invalid email kept as both name and email
|
||||
|
||||
partners = self.env['res.partner']
|
||||
for samples, (expected_name, expected_email) in [
|
||||
(email_dupes_samples, (email_expected_name, email_expected_email)),
|
||||
(name_dupes_samples, (name_expected_name, name_expected_email)),
|
||||
]:
|
||||
with self.subTest(samples=samples, expected_name=expected_name, expected_email=expected_email):
|
||||
with self.mockPartnerCalls():
|
||||
partner_list = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
|
||||
samples,
|
||||
additional_values=None,
|
||||
)
|
||||
# calls
|
||||
self.assertEqual(self._mock_partner_create.call_count, 1)
|
||||
self.assertEqual(self._mock_partner_search.call_count, 1)
|
||||
self.assertEqual(len(self._new_partners), 1)
|
||||
# results
|
||||
self.assertEqual(len(partner_list), len(samples))
|
||||
self.assertTrue(len(set(partner.id for partner in partner_list)) == 1 and partner_list[0].id, 'Should have a unique new partner')
|
||||
for partner in partner_list:
|
||||
self.assertEqual(partner.email, expected_email)
|
||||
self.assertEqual(partner.name, expected_name)
|
||||
|
||||
partners += partner_list[0]
|
||||
|
||||
self.assertEqual(len(partners), 2,
|
||||
'Should have created one partner for valid email, one for invalid email')
|
||||
|
||||
new_samples = [
|
||||
'"Another Customer" <test.customer2@TEST.DUPE.EXAMPLE.COM', # actually a new valid email
|
||||
'"First Duplicate" <test.customer@TEST.DUPE.example.com', # duplicated of valid email created above
|
||||
'test.customer.invalid.email', # duplicate of an invalid email created above
|
||||
]
|
||||
with self.mockPartnerCalls():
|
||||
new_partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
|
||||
new_samples,
|
||||
additional_values=None,
|
||||
)
|
||||
# calls
|
||||
self.assertEqual(self._mock_partner_create.call_count, 1)
|
||||
self.assertEqual(self._mock_partner_search.call_count, 1,
|
||||
'Search once, even with both normalized and invalid emails')
|
||||
self.assertEqual(len(self._new_partners), 1)
|
||||
# results
|
||||
self.assertEqual(len(new_partners), len(new_samples))
|
||||
self.assertEqual(new_partners[0].email, "test.customer2@test.dupe.example.com")
|
||||
self.assertEqual(new_partners[0].name, "Another Customer")
|
||||
self.assertEqual(new_partners[1], partners[0])
|
||||
|
||||
other_samples = [
|
||||
'"Another Duplicate" <test.customer2@TEST.DUPE.EXAMPLE.COM',
|
||||
'"First Duplicate2" <test.customer@TEST.DUPE.example.com',
|
||||
'"Third Customer" <test.customer3@test.dupe.example.com',
|
||||
'"Falsy" <falsy>',
|
||||
'falsy',
|
||||
' ',
|
||||
]
|
||||
with self.mockPartnerCalls():
|
||||
other_partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
|
||||
other_samples,
|
||||
additional_values=None,
|
||||
)
|
||||
# calls
|
||||
self.assertEqual(self._mock_partner_create.call_count, 1)
|
||||
self.assertEqual(self._mock_partner_search.call_count, 1)
|
||||
self.assertEqual(len(self._new_partners), 3)
|
||||
# results
|
||||
self.assertEqual(len(other_partners), len(other_samples))
|
||||
self.assertEqual(other_partners[0], new_partners[0], 'Should take already existing partner')
|
||||
self.assertEqual(other_partners[1], partners[0], 'Should take already existing partner')
|
||||
self.assertEqual(other_partners[2].email, "test.customer3@test.dupe.example.com")
|
||||
self.assertEqual(other_partners[2].name, "Third Customer")
|
||||
self.assertEqual(other_partners[3].email, '"Falsy" <falsy>')
|
||||
self.assertEqual(other_partners[3].name, '"Falsy" <falsy>')
|
||||
self.assertEqual(other_partners[4].email, "falsy")
|
||||
self.assertEqual(other_partners[4].name, "falsy")
|
||||
|
||||
def test_res_partner_log_portal_group(self):
|
||||
Users = self.env['res.users']
|
||||
subtype_note = self.env.ref('mail.mt_note')
|
||||
|
||||
@@ -820,7 +820,11 @@ class Partner(models.Model):
|
||||
|
||||
Otherwise: default, everything is set as the name. Starting from 13.3
|
||||
returned email will be normalized to have a coherent encoding.
|
||||
"""
|
||||
|
||||
:return: name, email (normalized if possible)
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return '', ''
|
||||
name, email = '', ''
|
||||
split_results = tools.email_split_tuples(text)
|
||||
if split_results:
|
||||
|
||||
Reference in New Issue
Block a user