Skip to content
Merged
28 changes: 14 additions & 14 deletions merge_duplicate_contacts/tests/test_merge_contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ def test_merge_contact(self):
{
"name": "test1",
"email": "test@example.com",
"phone": 987654,
"phone": "+49987654",
}
)
partner_pool |= self.partner1
self.partner2 = partner_pool.create(
{
"name": "test1 (copy)",
"email": "test@example.com",
"phone": 987654,
"phone": "+49987654",
}
)
partner_pool |= self.partner2
Expand All @@ -39,7 +39,7 @@ def test_merge_partner_swap(self):
{
"name": "test1",
"email": "test@example.com",
"phone": 987654,
"phone": "+49987654",
"vat": "BE0477472701",
}
)
Expand All @@ -48,7 +48,7 @@ def test_merge_partner_swap(self):
{
"name": "test1 (copy)",
"email": "test@example.com",
"phone": 987654321,
"phone": "+49987654321",
"company_id": self.env.user.company_id.id,
}
)
Expand Down Expand Up @@ -131,7 +131,7 @@ def test_merge_partner_right_swap_records(self):
{
"name": "test1",
"email": "test@example.com",
"phone": 987654,
"phone": "+49987654",
"vat": "BE0477472701",
}
)
Expand All @@ -140,7 +140,7 @@ def test_merge_partner_right_swap_records(self):
{
"name": "test1 (copy)",
"email": "test@example.com",
"phone": 987654321,
"phone": "+49987654321",
"company_id": self.env.user.company_id.id,
}
)
Expand Down Expand Up @@ -203,7 +203,7 @@ def test_merge_partner_right_swap_records(self):
"keep1": True,
"company_id": self.env.user.company_id.id,
"company_name": "Test Abc",
"phone": "987654",
"phone": "+49987654",
"mobile": "+156778978",
"street": "test street",
"street11": "test street 11",
Expand Down Expand Up @@ -291,7 +291,7 @@ def test_merge_partner_left_swap_records(self):
{
"name": "test1",
"email": "test@example.com",
"phone": 987654,
"phone": "+49987654",
"vat": "BE0477472701",
}
)
Expand All @@ -300,7 +300,7 @@ def test_merge_partner_left_swap_records(self):
{
"name": "test1 (copy)",
"email": "test@example.com",
"phone": 987654321,
"phone": "+49987654321",
"company_id": self.env.user.company_id.id,
}
)
Expand Down Expand Up @@ -363,7 +363,7 @@ def test_merge_partner_left_swap_records(self):
"keep1": True,
"company_id2": self.env.user.company_id.id,
"company_name2": "Test Abc",
"phone2": "987654",
"phone2": "+49987654",
"mobile2": "+156778978",
"street2": "test street",
"street22": "test street 22",
Expand Down Expand Up @@ -450,15 +450,15 @@ def test_merge_swap(self):
{
"name": "test1",
"email": "test@example.com",
"phone": 987654,
"phone": "+49987654",
}
)
partner_pool |= self.partner1
self.partner2 = partner_pool.create(
{
"name": "test1 (copy)",
"email": "test@example.com",
"phone": 987654321,
"phone": "+49987654321",
}
)
partner_pool |= self.partner2
Expand Down Expand Up @@ -540,15 +540,15 @@ def test_merge_skip(self):
{
"name": "test1",
"email": "test@example.com",
"phone": 987654,
"phone": "+49987654",
}
)
partner_pool |= self.partner1
self.partner2 = partner_pool.create(
{
"name": "test1 (copy)",
"email": "test@example.com",
"phone": 987654321,
"phone": "+49987654321",
}
)
partner_pool |= self.partner2
Expand Down
23 changes: 23 additions & 0 deletions phone_validation_e164/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Phone Validation E164
=====================

This module enforces E.164 phone number formatting for `res.partner` records, overriding the default Odoo core behavior.
The module also adds logic to handle phone number formatting during website/API saves, guaranteeing consistency regardless of how records are created or updated.

Features
--------

- Overrides Odoo's phone validation to use E.164 format for phone and mobile fields.
- Applies E.164 formatting on both onchange and save actions (including website/API calls).
- Handles country-specific formatting using the `phonenumbers` library.

Dependencies
------------

- Odoo `phone_validation` module
- Python `phonenumbers` library

Author
------

Nitrokey GmbH
1 change: 1 addition & 0 deletions phone_validation_e164/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
14 changes: 14 additions & 0 deletions phone_validation_e164/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Phone Validation E164",
"summary": "Enforces E.164 phone number formatting for res.partner records."
"Phone and mobile fields are validated and saved in E.164 format,"
"overriding Odoo core behavior. Supports website/API saves.",
"category": "Phone",
"license": "AGPL-3",
"version": "18.0.1.0.0",
"external_dependencies": {"python": ["phonenumbers"]},
"depends": ["phone_validation"],
"author": "Nitrokey GmbH",
"website": "https://github.com/Nitrokey/odoo-modules",
}
1 change: 1 addition & 0 deletions phone_validation_e164/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import res_partner
89 changes: 89 additions & 0 deletions phone_validation_e164/models/res_partner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import logging

import phonenumbers

from odoo import api, models
from odoo.exceptions import UserError

from odoo.addons.phone_validation.tools.phone_validation import phone_parse

_logger = logging.getLogger(__name__)


def _format_number_to_e164(number_str, country):
"""
Your E164 helper function.
Takes a number string and a res.country record.
"""
if not number_str:
return number_str

number_str = str(number_str)
country_code = country.code if country else None

try:
phone_nbr = phone_parse(number_str, country_code)
return phonenumbers.format_number(
phone_nbr, phonenumbers.PhoneNumberFormat.E164
)
except (phonenumbers.phonenumberutil.NumberParseException, UserError) as e:
_logger.warning(f"Could not apply E164 formatting: {number_str} ({e})")
return number_str


class ResPartner(models.Model):
_inherit = "res.partner"

# --- 1. FIX Odoo Core Behavior ---
# Override the @onchange methods to use E164 instead of INTERNATIONAL

def _format_onchange_number(self, field_name):
number = getattr(self, field_name)
country = self.country_id if self.country_id else None
if number:
formatted = _format_number_to_e164(number, country)
# If formatting failed and number starts with '+', try without country
if formatted == number and number.startswith("+"):
formatted_intl = _format_number_to_e164(number, None)
if formatted_intl != number:
setattr(self, field_name, formatted_intl)
return
setattr(self, field_name, formatted)

@api.onchange("phone", "country_id", "company_id")
def _onchange_phone_validation(self):
self._format_onchange_number("phone")

@api.onchange("mobile", "country_id", "company_id")
def _onchange_mobile_validation(self):
self._format_onchange_number("mobile")

# --- 2. ADD Save Logic (for Website/API) ---
# Catches all save actions that bypass @onchange

def _get_country_for_phone_format(self, vals):
"""Helper to find the country (new or existing)"""
if vals.get("country_id"):
return self.env["res.country"].browse(vals["country_id"])
if self:
return self[0].country_id
return self.env["res.country"].browse([])

@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
country = self._get_country_for_phone_format(vals)
if vals.get("phone"):
vals["phone"] = _format_number_to_e164(vals["phone"], country)
if vals.get("mobile"):
vals["mobile"] = _format_number_to_e164(vals["mobile"], country)
return super().create(vals_list)

def write(self, vals):
if "phone" in vals or "mobile" in vals:
country = self._get_country_for_phone_format(vals)
if vals.get("phone"):
vals["phone"] = _format_number_to_e164(vals["phone"], country)
if vals.get("mobile"):
vals["mobile"] = _format_number_to_e164(vals["mobile"], country)
return super().write(vals)
1 change: 1 addition & 0 deletions phone_validation_e164/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_e164
104 changes: 104 additions & 0 deletions phone_validation_e164/tests/test_e164.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from odoo.tests.common import TransactionCase


class TestPhoneValidationE164(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.country = cls.env["res.country"].search([("code", "=", "DE")], limit=1)

def test_format_number_to_e164_with_int(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
"phone": 987654, # Pass as int
"country_id": self.country.id,
}
)
# Should be formatted as string, not raise TypeError
assert isinstance(partner.phone, str)
# Should start with '+' if formatted to E164 (if valid)
assert partner.phone.startswith("+") or partner.phone == "987654"

def test_format_number_to_e164_with_str(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
"phone": "+49 987654",
"country_id": self.country.id,
}
)
assert partner.phone.startswith("+49")

def test_format_number_to_e164_with_invalid(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
"phone": "notaphone",
"country_id": self.country.id,
}
)
# Should return the original string if formatting fails
assert partner.phone == "notaphone"

def test_onchange_phone(self):
partner = self.env["res.partner"].new(
{
"phone": "+49 987654",
"country_id": self.country.id,
}
)
partner._onchange_phone_validation()
assert partner.phone.startswith("+49")

def test_onchange_mobile(self):
partner = self.env["res.partner"].new(
{
"mobile": "+49 123456",
"country_id": self.country.id,
}
)
partner._onchange_mobile_validation()
assert partner.mobile.startswith("+49")

def test_write_phone(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
"phone": "987654",
"country_id": self.country.id,
}
)
partner.write({"phone": "+49 987654"})
assert partner.phone.startswith("+49")

def test_write_mobile(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
"mobile": "123456",
"country_id": self.country.id,
}
)
partner.write({"mobile": "+49 123456"})
assert partner.mobile.startswith("+49")

def test_create_without_country(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
"phone": "+49 987654",
}
)
assert partner.phone.startswith("+49")

def test_format_number_to_e164_with_german_country(self):
# Pass a local German number without +, should result in +49
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
"phone": "987654321",
"country_id": self.country.id,
}
)
assert partner.phone.startswith("+49")