diff --git a/partner_email_check/README.rst b/partner_email_check/README.rst index 9acf20d42c0..eb26befde67 100644 --- a/partner_email_check/README.rst +++ b/partner_email_check/README.rst @@ -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. diff --git a/partner_email_check/__manifest__.py b/partner_email_check/__manifest__.py index 5cbf437768e..6d614d92f05 100644 --- a/partner_email_check/__manifest__.py +++ b/partner_email_check/__manifest__.py @@ -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", diff --git a/partner_email_check/models/res_company.py b/partner_email_check/models/res_company.py index d65a7c37726..97f3d05885b 100644 --- a/partner_email_check/models/res_company.py +++ b/partner_email_check/models/res_company.py @@ -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", diff --git a/partner_email_check/models/res_config_settings.py b/partner_email_check/models/res_config_settings.py index 4128377ac27..ec2675facec 100644 --- a/partner_email_check/models/res_config_settings.py +++ b/partner_email_check/models/res_config_settings.py @@ -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, diff --git a/partner_email_check/models/res_partner.py b/partner_email_check/models/res_partner.py index 7f797434274..b13bc7d07f8 100644 --- a/partner_email_check/models/res_partner.py +++ b/partner_email_check/models/res_partner.py @@ -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(): @@ -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 diff --git a/partner_email_check/readme/CONFIGURE.md b/partner_email_check/readme/CONFIGURE.md index ab7d7a64cc7..60a7a49715a 100644 --- a/partner_email_check/readme/CONFIGURE.md +++ b/partner_email_check/readme/CONFIGURE.md @@ -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. diff --git a/partner_email_check/static/description/index.html b/partner_email_check/static/description/index.html index 6150516cf97..9e91181079d 100644 --- a/partner_email_check/static/description/index.html +++ b/partner_email_check/static/description/index.html @@ -405,6 +405,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:
+To validate that email addresses are deliverable (that the hostname exists), use the “Check deliverability of email addresses”/partner_email_check_check_deliverability setting.
diff --git a/partner_email_check/tests/test_partner_email_check.py b/partner_email_check/tests/test_partner_email_check.py index 3082103ea84..6c18ea3ec20 100644 --- a/partner_email_check/tests/test_partner_email_check.py +++ b/partner_email_check/tests/test_partner_email_check.py @@ -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 @@ -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 diff --git a/partner_email_check/views/base_config_view.xml b/partner_email_check/views/base_config_view.xml index b45ba42dab7..99eeb2e7dcd 100644 --- a/partner_email_check/views/base_config_view.xml +++ b/partner_email_check/views/base_config_view.xml @@ -42,6 +42,15 @@