Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions partner_email_check/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ To not allow multiple partners to have the same email address, use the
"Filter duplicate email
addresses"/``partner_email_check_filter_duplicates`` setting.

When duplicate filtering is enabled, the "Duplicate email
scope"/``partner_email_check_duplicate_scope`` setting controls how
widely addresses must be unique:

- *Across all companies* (default): an email address may only be used
once in the whole database.
- *Within the same company*: the same email address may be reused by a
partner of another company, but must stay unique inside each company.

To validate that email addresses are deliverable (that the hostname
exists), use the "Check deliverability of email
addresses"/``partner_email_check_check_deliverability`` setting.
Expand Down
2 changes: 1 addition & 1 deletion partner_email_check/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

{
"name": "Email Format Checker",
"version": "19.0.1.0.0",
"version": "19.0.2.0.0",
"summary": "Validate email address field",
"author": "Komit, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/partner-contact",
Expand Down
15 changes: 15 additions & 0 deletions partner_email_check/models/res_company.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ class ResCompany(models.Model):
string="Filter duplicate partner email addresses",
help="Don't allow multiple partners to have the same email address.",
)
partner_email_check_duplicate_scope = fields.Selection(
selection=[
("global", "Across all companies"),
("company", "Within the same company"),
],
string="Duplicate email scope",
default="global",
required=True,
help="Define which partners are compared when filtering duplicate email "
"addresses:\n"
"- Across all companies: an email address may only be used once in the "
"whole database.\n"
"- Within the same company: the same email address may be reused by a "
"partner of another company, but must stay unique inside each company.",
)
partner_email_check_check_deliverability = fields.Boolean(
string="Check deliverability of email addresses",
help="Don't allow email addresses with providers that don't exist",
Expand Down
5 changes: 5 additions & 0 deletions partner_email_check/models/res_config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ class ResConfigSettings(models.TransientModel):
readonly=False,
)

partner_email_check_duplicate_scope = fields.Selection(
related="company_id.partner_email_check_duplicate_scope",
readonly=False,
)

partner_email_check_check_deliverability = fields.Boolean(
related="company_id.partner_email_check_check_deliverability",
readonly=False,
Expand Down
62 changes: 50 additions & 12 deletions partner_email_check/models/res_partner.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,55 @@ def email_check(self, emails):

@api.constrains("email")
def _check_email_unique(self):
if self._should_filter_duplicates():
for rec in self.filtered("email"):
if "," in rec.email:
raise UserError(
self.env._(
"Field contains multiple email addresses. This is "
"not supported when duplicate email addresses are "
"not allowed."
)
if not self._should_filter_duplicates():
return
global_scope = self._should_filter_duplicates_globally()
# Constraint methods run as superuser (see BaseModel._validate_fields),
# so this search is authoritative across all records and companies,
# regardless of the acting user's record rules. The acting user's own
# access rights are only used to decide how much detail to disclose in
# the error message.
acting_user_model = self.sudo(False)
for rec in self.filtered("email"):
if "," in rec.email:
raise UserError(
self.env._(
"Field contains multiple email addresses. This is "
"not supported when duplicate email addresses are "
"not allowed."
)
if self.search_count([("email", "=", rec.email), ("id", "!=", rec.id)]):
raise UserError(
self.env._("Email '%s' is already in use.", rec.email.strip())
)
domain = [("email", "=", rec.email), ("id", "!=", rec.id)]
if not global_scope:
# Per-company scope: only records sharing the same company
# (including company-agnostic records, company_id = False) are
# considered duplicates.
domain.append(("company_id", "=", rec.company_id.id))
conflict = self.search(domain, limit=1)
if not conflict:
continue
# Disclose the conflicting record only if the acting user is
# actually allowed to see it; otherwise keep its identity private.
if acting_user_model.search_count([("id", "=", conflict.id)], limit=1):
raise UserError(
self.env._(
"Email address %(email)s is already in use by "
"%(partner)s (ID: %(partner_id)s). Please input "
"another email address or use the existing record.",
email=rec.email.strip(),
partner=conflict.display_name,
partner_id=conflict.id,
)
)
raise UserError(
self.env._(
"Email address %(email)s is already in use by a record "
"you do not have access to. Please input a different "
"email address, or contact your system administrator to "
"request access.",
email=rec.email.strip(),
)
)

def _normalize_email(self, email):
if not self._should_check_syntax():
Expand All @@ -66,6 +101,9 @@ def _should_check_syntax(self):
def _should_filter_duplicates(self):
return self.env.company.partner_email_check_filter_duplicates

def _should_filter_duplicates_globally(self):
return self.env.company.partner_email_check_duplicate_scope == "global"

def _should_check_deliverability(self):
return self.env.company.partner_email_check_check_deliverability

Expand Down
9 changes: 9 additions & 0 deletions partner_email_check/readme/CONFIGURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ To not allow multiple partners to have the same email address, use the
"Filter duplicate email
addresses"/`partner_email_check_filter_duplicates` setting.

When duplicate filtering is enabled, the "Duplicate email
scope"/`partner_email_check_duplicate_scope` setting controls how widely
addresses must be unique:

- *Across all companies* (default): an email address may only be used once in
the whole database.
- *Within the same company*: the same email address may be reused by a partner
of another company, but must stay unique inside each company.

To validate that email addresses are deliverable (that the hostname
exists), use the "Check deliverability of email
addresses"/`partner_email_check_check_deliverability` setting.
9 changes: 9 additions & 0 deletions partner_email_check/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,15 @@ <h2><a class="toc-backref" href="#toc-entry-1">Configuration</a></h2>
<p>To not allow multiple partners to have the same email address, use the
“Filter duplicate email
addresses”/<tt class="docutils literal">partner_email_check_filter_duplicates</tt> setting.</p>
<p>When duplicate filtering is enabled, the “Duplicate email
scope”/<tt class="docutils literal">partner_email_check_duplicate_scope</tt> setting controls how
widely addresses must be unique:</p>
<ul class="simple">
<li><em>Across all companies</em> (default): an email address may only be used
once in the whole database.</li>
<li><em>Within the same company</em>: the same email address may be reused by a
partner of another company, but must stay unique inside each company.</li>
</ul>
<p>To validate that email addresses are deliverable (that the hostname
exists), use the “Check deliverability of email
addresses”/<tt class="docutils literal">partner_email_check_check_deliverability</tt> setting.</p>
Expand Down
106 changes: 106 additions & 0 deletions partner_email_check/tests/test_partner_email_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).


from odoo import Command
from odoo.exceptions import UserError, ValidationError
from odoo.tests.common import TransactionCase

Expand Down Expand Up @@ -100,6 +101,111 @@ def test_duplicate_addresses_allowed_by_default(self):
)
self.test_partner.email = "email@domain.tld"

def test_duplicate_per_company_scope_allows_other_company(self):
"""Under per-company scope the same email may be reused by a partner of
another company."""
self.disallow_duplicates()
self.env.company.partner_email_check_duplicate_scope = "company"
company_b = self.env["res.company"].create({"name": "Company B"})
self.env["res.partner"].create(
{
"name": "company a partner",
"email": "shared@domain.tld",
"company_id": self.env.company.id,
}
)
# Same email in another company is allowed under per-company scope.
partner_b = self.env["res.partner"].create(
{
"name": "company b partner",
"email": "shared@domain.tld",
"company_id": company_b.id,
}
)
self.assertEqual(partner_b.email, "shared@domain.tld")

def test_duplicate_per_company_scope_blocks_same_company(self):
"""Per-company scope still blocks duplicates inside one company."""
self.disallow_duplicates()
self.env.company.partner_email_check_duplicate_scope = "company"
self.env["res.partner"].create(
{
"name": "first",
"email": "shared@domain.tld",
"company_id": self.env.company.id,
}
)
with self.assertRaises(UserError):
self.env["res.partner"].create(
{
"name": "second",
"email": "shared@domain.tld",
"company_id": self.env.company.id,
}
)

def test_duplicate_global_scope_blocks_other_company(self):
"""Under global scope (default) the email must be unique across
companies."""
self.disallow_duplicates()
self.assertEqual(self.env.company.partner_email_check_duplicate_scope, "global")
company_b = self.env["res.company"].create({"name": "Company B"})
self.env["res.partner"].create(
{
"name": "company a partner",
"email": "shared@domain.tld",
"company_id": self.env.company.id,
}
)
with self.assertRaises(UserError):
self.env["res.partner"].create(
{
"name": "company b partner",
"email": "shared@domain.tld",
"company_id": company_b.id,
}
)

def test_duplicate_in_inaccessible_record_disallowed(self):
"""A duplicate hidden from the user by record rules is still blocked,
with a dedicated message that does not disclose the conflicting record.
"""
self.disallow_duplicates()
hidden_partner = self.env["res.partner"].create(
{"name": "hidden", "email": "shared@domain.tld"}
)
# An internal user allowed to create partners (so creation does not
# trip on unrelated access checks, e.g. base_partner_sequence reading
# ir.sequence) but blocked from reading the conflicting record.
groups = self.env.ref("base.group_user") | self.env.ref(
"base.group_partner_manager"
)
restricted_user = self.env["res.users"].create(
{
"name": "Restricted",
"login": "restricted_user",
"group_ids": [Command.set(groups.ids)],
}
)
# Global rule (no groups) so it applies to every non-superuser
# regardless of the user's other groups; match by id to stay
# independent of fields other modules may populate.
self.env["ir.rule"].create(
{
"name": "Hide conflicting partner from everyone",
"model_id": self.env.ref("base.model_res_partner").id,
"groups": [Command.set([])],
"domain_force": f"[('id', '!=', {hidden_partner.id})]",
}
)
partner_model = self.env["res.partner"].with_user(restricted_user)
# The restricted user cannot see the flagged partner ...
self.assertFalse(partner_model.search([("email", "=", "shared@domain.tld")]))
# ... but creating a duplicate is still blocked with the access message.
with self.assertRaises(UserError) as catcher:
partner_model.create({"name": "dup", "email": "shared@domain.tld"})
self.assertIn("do not have access", str(catcher.exception))

def check_deliverability(self):
self.env.company.partner_email_check_check_deliverability = True

Expand Down
9 changes: 9 additions & 0 deletions partner_email_check/views/base_config_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@
<div class="text-muted">
Require partner email addresses to be unique
</div>
<div
class="mt8"
invisible="not partner_email_check_filter_duplicates"
>
<field
name="partner_email_check_duplicate_scope"
widget="radio"
/>
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box">
Expand Down
Loading