Compare commits
8 Commits
0d7a57d321
...
18.0-fix-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3fa6b8d6e | ||
|
|
e161f431ec | ||
|
|
8640e2eefa | ||
|
|
a213def5a1 | ||
|
|
c8d6b8f6c4 | ||
|
|
6e776cbe64 | ||
|
|
0c7120cd68 | ||
|
|
946b69e722 |
@@ -21,7 +21,25 @@ It also modify the partner form vieww for a better understanding of the geolocat
|
|||||||
Configuration
|
Configuration
|
||||||
=============
|
=============
|
||||||
|
|
||||||
No configuration needed.
|
The OpenStreetMap Nominatim geocoding service requires a genuine, identifying
|
||||||
|
``User-Agent`` header with a valid contact address (see the `Nominatim usage
|
||||||
|
policy <https://operations.osmfoundation.org/policies/nominatim/>`_). The
|
||||||
|
default Odoo ``User-Agent`` is shared by many instances and gets rate-limited or
|
||||||
|
blocked (HTTP 429 "Too many requests").
|
||||||
|
|
||||||
|
To fix this, set the system parameter (Settings > Technical > System
|
||||||
|
Parameters):
|
||||||
|
|
||||||
|
- **Key**: ``partner_geolocalize_usability.user_agent``
|
||||||
|
- **Value**: a User-Agent identifying your deployment and a valid contact,
|
||||||
|
e.g. ``acme-crm/1.0 (contact: gis@acme.example)``
|
||||||
|
|
||||||
|
The module ships this parameter with a placeholder value; you MUST replace it
|
||||||
|
with your own, otherwise Nominatim will reject the requests.
|
||||||
|
|
||||||
|
Requests to Nominatim are additionally throttled to one request per second to
|
||||||
|
comply with the usage policy, so mass geolocation of many partners is slow by
|
||||||
|
design (roughly one partner per second).
|
||||||
|
|
||||||
Known issues / Roadmap
|
Known issues / Roadmap
|
||||||
======================
|
======================
|
||||||
|
|||||||
@@ -17,10 +17,11 @@
|
|||||||
],
|
],
|
||||||
"qweb": [],
|
"qweb": [],
|
||||||
"external_dependencies": {
|
"external_dependencies": {
|
||||||
"python": [],
|
"python": ["requests"],
|
||||||
},
|
},
|
||||||
# always loaded
|
# always loaded
|
||||||
"data": [
|
"data": [
|
||||||
|
"data/ir_config_parameter.xml",
|
||||||
"views/res_partner.xml",
|
"views/res_partner.xml",
|
||||||
],
|
],
|
||||||
# only loaded in demonstration mode
|
# only loaded in demonstration mode
|
||||||
|
|||||||
16
partner_geolocalize_usability/data/ir_config_parameter.xml
Normal file
16
partner_geolocalize_usability/data/ir_config_parameter.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!--
|
||||||
|
User-Agent sent to the OpenStreetMap Nominatim geocoding service.
|
||||||
|
|
||||||
|
Nominatim's usage policy requires a genuine User-Agent identifying the
|
||||||
|
application and a valid contact address. The value below is a PLACEHOLDER:
|
||||||
|
each deployment MUST replace it with its own service name and contact,
|
||||||
|
e.g. "acme-crm/1.0 (contact: gis@acme.example)". Leaving the placeholder
|
||||||
|
will result in HTTP 429 (Too many requests) from Nominatim.
|
||||||
|
-->
|
||||||
|
<record id="param_nominatim_user_agent" model="ir.config_parameter">
|
||||||
|
<field name="key">partner_geolocalize_usability.user_agent</field>
|
||||||
|
<field name="value">SET-ME partner_geolocalize_usability (contact: you@example.invalid)</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
|
||||||
|
from . import base_geocoder
|
||||||
from . import res_partner
|
from . import res_partner
|
||||||
|
|||||||
79
partner_geolocalize_usability/models/base_geocoder.py
Normal file
79
partner_geolocalize_usability/models/base_geocoder.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# System parameter holding the User-Agent sent to Nominatim.
|
||||||
|
# Nominatim's usage policy requires a genuine, identifying User-Agent with a
|
||||||
|
# valid contact. The stock Odoo User-Agent is shared by thousands of instances
|
||||||
|
# and is rate-limited/blocked (HTTP 429), so we let each deployment set its own.
|
||||||
|
USER_AGENT_PARAM = "partner_geolocalize_usability.user_agent"
|
||||||
|
|
||||||
|
# Placeholder shown until the deployment sets a real value. It intentionally
|
||||||
|
# does NOT identify any real service so an unconfigured instance fails loudly
|
||||||
|
# rather than silently reusing a banned generic agent.
|
||||||
|
USER_AGENT_PLACEHOLDER = (
|
||||||
|
"SET-ME partner_geolocalize_usability (contact: you@example.invalid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nominatim's public API allows at most 1 request per second. We wait slightly
|
||||||
|
# more than a second between calls to stay safely under the limit.
|
||||||
|
NOMINATIM_MIN_INTERVAL = 1.1
|
||||||
|
|
||||||
|
|
||||||
|
class GeoCoder(models.AbstractModel):
|
||||||
|
_inherit = "base.geocoder"
|
||||||
|
|
||||||
|
def _get_nominatim_user_agent(self):
|
||||||
|
"""Return the configured User-Agent for Nominatim requests.
|
||||||
|
|
||||||
|
Falls back to an obvious placeholder so a misconfigured instance is easy
|
||||||
|
to spot in logs (and so we never silently reuse Odoo's banned agent).
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.env["ir.config_parameter"]
|
||||||
|
.sudo()
|
||||||
|
.get_param(USER_AGENT_PARAM, USER_AGENT_PLACEHOLDER)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _call_openstreetmap(self, addr, **kw):
|
||||||
|
"""Query Nominatim with a configurable User-Agent and rate limiting.
|
||||||
|
|
||||||
|
Overrides the stock implementation to fix two problems that cause
|
||||||
|
HTTP 429 ("Too many requests"):
|
||||||
|
|
||||||
|
* the shared/generic Odoo User-Agent is blocked by Nominatim -> use a
|
||||||
|
deployment-specific one from a system parameter;
|
||||||
|
* bursts of requests exceed the 1 req/s policy -> throttle between calls.
|
||||||
|
"""
|
||||||
|
if not addr:
|
||||||
|
_logger.info("invalid address given")
|
||||||
|
return None
|
||||||
|
url = "https://nominatim.openstreetmap.org/search"
|
||||||
|
try:
|
||||||
|
headers = {"User-Agent": self._get_nominatim_user_agent()}
|
||||||
|
# Respect Nominatim's 1 req/s policy. Sleeping before the call keeps
|
||||||
|
# the throttle effective even when callers loop over many records.
|
||||||
|
time.sleep(NOMINATIM_MIN_INTERVAL)
|
||||||
|
response = requests.get(
|
||||||
|
url, headers=headers, params={"format": "json", "q": addr}
|
||||||
|
)
|
||||||
|
_logger.info("openstreetmap nominatim service called")
|
||||||
|
if response.status_code != 200:
|
||||||
|
_logger.warning(
|
||||||
|
"Request to openstreetmap failed.\nCode: %s\nContent: %s",
|
||||||
|
response.status_code,
|
||||||
|
response.content,
|
||||||
|
)
|
||||||
|
result = response.json()
|
||||||
|
except Exception as e:
|
||||||
|
self._raise_query_error(e)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
geo = result[0]
|
||||||
|
return float(geo["lat"]), float(geo["lon"])
|
||||||
1
partner_geolocalize_usability/tests/__init__.py
Normal file
1
partner_geolocalize_usability/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import test_base_geocoder
|
||||||
92
partner_geolocalize_usability/tests/test_base_geocoder.py
Normal file
92
partner_geolocalize_usability/tests/test_base_geocoder.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
from ..models.base_geocoder import (
|
||||||
|
NOMINATIM_MIN_INTERVAL,
|
||||||
|
USER_AGENT_PARAM,
|
||||||
|
USER_AGENT_PLACEHOLDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MODULE = "odoo.addons.partner_geolocalize_usability.models.base_geocoder"
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_response(payload, status_code=200):
|
||||||
|
response = MagicMock()
|
||||||
|
response.status_code = status_code
|
||||||
|
response.json.return_value = payload
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseGeocoder(TransactionCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.geocoder = cls.env["base.geocoder"]
|
||||||
|
cls.Param = cls.env["ir.config_parameter"].sudo()
|
||||||
|
|
||||||
|
def test_uses_configured_user_agent(self):
|
||||||
|
# A deployment-specific User-Agent must be sent to Nominatim.
|
||||||
|
self.Param.set_param(USER_AGENT_PARAM, "acme/1.0 (contact: x@acme.test)")
|
||||||
|
|
||||||
|
with patch(f"{_MODULE}.time.sleep"), patch(
|
||||||
|
f"{_MODULE}.requests.get",
|
||||||
|
return_value=_fake_response([{"lat": "1.5", "lon": "2.5"}]),
|
||||||
|
) as mocked_get:
|
||||||
|
result = self.geocoder._call_openstreetmap("some address")
|
||||||
|
|
||||||
|
self.assertEqual(result, (1.5, 2.5))
|
||||||
|
_, kwargs = mocked_get.call_args
|
||||||
|
self.assertEqual(
|
||||||
|
kwargs["headers"]["User-Agent"], "acme/1.0 (contact: x@acme.test)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_falls_back_to_placeholder_user_agent(self):
|
||||||
|
# Without configuration, the obvious placeholder is used (never Odoo's
|
||||||
|
# banned generic agent).
|
||||||
|
self.Param.set_param(USER_AGENT_PARAM, False)
|
||||||
|
|
||||||
|
with patch(f"{_MODULE}.time.sleep"), patch(
|
||||||
|
f"{_MODULE}.requests.get",
|
||||||
|
return_value=_fake_response([{"lat": "1.0", "lon": "2.0"}]),
|
||||||
|
) as mocked_get:
|
||||||
|
self.geocoder._call_openstreetmap("addr")
|
||||||
|
|
||||||
|
_, kwargs = mocked_get.call_args
|
||||||
|
self.assertEqual(
|
||||||
|
kwargs["headers"]["User-Agent"], USER_AGENT_PLACEHOLDER
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_throttles_before_request(self):
|
||||||
|
# The 1 req/s policy is enforced by sleeping before each call.
|
||||||
|
self.Param.set_param(USER_AGENT_PARAM, "acme/1.0 (contact: x@acme.test)")
|
||||||
|
|
||||||
|
with patch(f"{_MODULE}.time.sleep") as mocked_sleep, patch(
|
||||||
|
f"{_MODULE}.requests.get",
|
||||||
|
return_value=_fake_response([{"lat": "1.0", "lon": "2.0"}]),
|
||||||
|
):
|
||||||
|
self.geocoder._call_openstreetmap("addr")
|
||||||
|
|
||||||
|
mocked_sleep.assert_called_once_with(NOMINATIM_MIN_INTERVAL)
|
||||||
|
|
||||||
|
def test_empty_result_returns_none(self):
|
||||||
|
# An unknown address (empty Nominatim result) must not raise IndexError.
|
||||||
|
self.Param.set_param(USER_AGENT_PARAM, "acme/1.0 (contact: x@acme.test)")
|
||||||
|
|
||||||
|
with patch(f"{_MODULE}.time.sleep"), patch(
|
||||||
|
f"{_MODULE}.requests.get", return_value=_fake_response([])
|
||||||
|
):
|
||||||
|
result = self.geocoder._call_openstreetmap("nowhere at all")
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_blank_address_returns_none_without_request(self):
|
||||||
|
# Empty address short-circuits: no HTTP call, no throttle.
|
||||||
|
with patch(f"{_MODULE}.time.sleep") as mocked_sleep, patch(
|
||||||
|
f"{_MODULE}.requests.get"
|
||||||
|
) as mocked_get:
|
||||||
|
result = self.geocoder._call_openstreetmap("")
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
mocked_get.assert_not_called()
|
||||||
|
mocked_sleep.assert_not_called()
|
||||||
@@ -1,44 +1,150 @@
|
|||||||
===============
|
================================
|
||||||
partner_phone_country_validation
|
Partner Phone Country Validation
|
||||||
===============
|
================================
|
||||||
|
|
||||||
Check the correct format of phone according to country when partner creation/validation
|
This module enhances phone number handling on partners by adding automatic
|
||||||
|
country detection from phone numbers and enforcing country consistency.
|
||||||
|
|
||||||
|
It extends the standard ``phone_validation`` module and the OCA ``base_phone``
|
||||||
|
module to provide additional features for phone number management.
|
||||||
|
|
||||||
|
**Table of contents**
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:local:
|
||||||
|
|
||||||
|
Features
|
||||||
|
========
|
||||||
|
|
||||||
|
Automatic Phone Number Normalization
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
When entering a phone number, the module automatically converts numbers
|
||||||
|
starting with ``00`` to the international ``+`` format:
|
||||||
|
|
||||||
|
* ``0033 6 12 34 56 78`` → ``+33 6 12 34 56 78``
|
||||||
|
* ``0041 76 123 45 67`` → ``+41 76 123 45 67``
|
||||||
|
|
||||||
|
This normalization happens before the standard formatting, ensuring
|
||||||
|
compatibility with the ``phonenumbers`` library.
|
||||||
|
|
||||||
|
Automatic Country Detection
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
When a phone number with an international prefix (``+xx``) is entered and
|
||||||
|
the partner has no country set, the module automatically detects and sets
|
||||||
|
the country based on the phone number prefix:
|
||||||
|
|
||||||
|
* Enter ``+33 6 12 34 56 78`` → Country is set to **France**
|
||||||
|
* Enter ``+32 4 123 45 67`` → Country is set to **Belgium**
|
||||||
|
* Enter ``+41 76 123 45 67`` → Country is set to **Switzerland**
|
||||||
|
|
||||||
|
This works for both ``phone`` and ``mobile`` fields.
|
||||||
|
|
||||||
|
Country Validation Constraint
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
The module enforces that a country must be set on the partner if a phone
|
||||||
|
or mobile number is present. This ensures data consistency and allows
|
||||||
|
proper phone number formatting.
|
||||||
|
|
||||||
|
If you try to save a partner with a phone number but no country, a
|
||||||
|
validation error will be raised.
|
||||||
|
|
||||||
|
How It Works
|
||||||
|
============
|
||||||
|
|
||||||
|
The module overrides the ``_onchange_phone_validation`` and
|
||||||
|
``_onchange_mobile_validation`` methods from the standard ``phone_validation``
|
||||||
|
module. The processing order is:
|
||||||
|
|
||||||
|
1. **Normalize**: Convert ``00xx`` format to ``+xx`` international format
|
||||||
|
2. **Detect country**: If no country is set, detect it from the phone prefix
|
||||||
|
3. **Format**: Call the standard formatting (via ``super()``) which formats
|
||||||
|
the number according to the partner's country
|
||||||
|
|
||||||
|
When the country is changed on a partner, both phone and mobile numbers
|
||||||
|
are automatically reformatted to match the new country's format.
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
============
|
||||||
|
|
||||||
|
This module depends on:
|
||||||
|
|
||||||
|
* ``base``: Odoo base module
|
||||||
|
* ``base_phone``: OCA module providing the ``phone.validation.mixin``
|
||||||
|
(from `connector-telephony <https://github.com/OCA/connector-telephony>`_)
|
||||||
|
|
||||||
|
The ``base_phone`` module itself depends on the standard ``phone_validation``
|
||||||
|
module which uses the `phonenumbers <https://github.com/daviddrysdale/python-phonenumbers>`_
|
||||||
|
Python library.
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
============
|
============
|
||||||
|
|
||||||
Use Odoo normal module installation procedure to install
|
1. Install the ``base_phone`` module from the OCA ``connector-telephony`` repository
|
||||||
``partner_phone_country_validation``.
|
2. Install this module using the standard Odoo module installation procedure
|
||||||
|
|
||||||
Known issues / Roadmap
|
Configuration
|
||||||
======================
|
=============
|
||||||
|
|
||||||
None yet.
|
No configuration is required. The module works automatically once installed.
|
||||||
|
|
||||||
|
Known Issues / Limitations
|
||||||
|
==========================
|
||||||
|
|
||||||
|
National Numbers Without International Prefix
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
When changing a partner's country, phone numbers in **national format**
|
||||||
|
(without international prefix) may not be reformatted if they are not
|
||||||
|
valid for the new country.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
* A French mobile ``06 12 34 56 78`` (without ``+33``) cannot be reformatted
|
||||||
|
to a Belgian format because ``06`` is not a valid Belgian mobile prefix.
|
||||||
|
* The ``phonenumbers`` library validates numbers and will keep the original
|
||||||
|
format if the number is invalid for the target country.
|
||||||
|
|
||||||
|
**Recommendation**: Always use international format (``+xx``) for phone numbers
|
||||||
|
to ensure proper handling when countries change.
|
||||||
|
|
||||||
Bug Tracker
|
Bug Tracker
|
||||||
===========
|
===========
|
||||||
|
|
||||||
Bugs are tracked on `our issues website <https://github.com/elabore-coop/partner_phone_country_validation/issues>`_. In case of
|
Bugs are tracked on `Elabore Git Issues <https://git.elabore.coop/Elabore/partner-tools/issues>`_.
|
||||||
trouble, please check there if your issue has already been
|
|
||||||
reported. If you spotted it first, help us smashing it by providing a
|
In case of trouble, please check there if your issue has already been reported.
|
||||||
detailed and welcomed feedback.
|
If you spotted it first, help us smash it by providing a detailed and welcomed
|
||||||
|
feedback.
|
||||||
|
|
||||||
Credits
|
Credits
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
Authors
|
||||||
|
-------
|
||||||
|
|
||||||
|
* `Elabore <https://elabore.coop>`_
|
||||||
|
|
||||||
Contributors
|
Contributors
|
||||||
------------
|
------------
|
||||||
|
|
||||||
* Boris Gallet - `Email<mailto:boris.gallet@elabore.coop>` - `Github<https://github.com/b0g>`
|
* Boris Gallet <boris.gallet@elabore.coop> (`GitHub <https://github.com/b0g>`_)
|
||||||
|
* Stéphan Sainléger <stephan.sainleger@elabore.coop>
|
||||||
|
|
||||||
Funders
|
Funders
|
||||||
-------
|
-------
|
||||||
|
|
||||||
The development of this module has been financially supported by:
|
The development of this module has been financially supported by:
|
||||||
* Elabore (https://elabore.coop)
|
|
||||||
|
|
||||||
|
* `Elabore <https://elabore.coop>`_
|
||||||
|
|
||||||
Maintainer
|
Maintainer
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
.. image:: https://elabore.coop/logo.png
|
||||||
|
:alt: Elabore
|
||||||
|
:target: https://elabore.coop
|
||||||
|
|
||||||
This module is maintained by Elabore.
|
This module is maintained by Elabore.
|
||||||
@@ -1,73 +1,54 @@
|
|||||||
from odoo import api, models, _
|
from odoo import api, models, _
|
||||||
from odoo.addons.partner_phone_country_validation.tools.get_country_from_phone_number import get_country_from_phone_number
|
from odoo.addons.partner_phone_country_validation.tools.get_country_from_phone_number import (
|
||||||
|
get_country_from_phone_number,
|
||||||
|
)
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
import logging
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class Partner(models.Model):
|
class Partner(models.Model):
|
||||||
_name = 'res.partner'
|
_name = "res.partner"
|
||||||
_inherit = ['res.partner', 'phone.validation.mixin']
|
_inherit = ["res.partner", "phone.validation.mixin"]
|
||||||
|
|
||||||
@api.constrains('country_id','phone','mobile')
|
@api.constrains("country_id", "phone", "mobile")
|
||||||
def _check_country_id(self):
|
def _check_country_id(self):
|
||||||
if not self.country_id and (self.phone or self.mobile):
|
for partner in self:
|
||||||
raise ValidationError(_('You must set a country for the phone number'))
|
if not partner.country_id and (partner.phone or partner.mobile):
|
||||||
|
raise ValidationError(_("You must set a country for the phone number"))
|
||||||
|
|
||||||
@api.onchange('country_id')
|
def _normalize_phone_number(self, number):
|
||||||
def _onchange_country(self):
|
"""Convert 00xx format to +xx international format."""
|
||||||
self.format_mobile_from_country()
|
if number and number.startswith("00"):
|
||||||
self.format_phone_from_country()
|
return "+" + number[2:]
|
||||||
|
|
||||||
def format_number_zerozero(self, number):
|
|
||||||
if number.startswith("00"):
|
|
||||||
number = "+"+number[2:]
|
|
||||||
return number
|
return number
|
||||||
|
|
||||||
def format_phone_from_country(self):
|
def _set_country_from_phone_number(self, phone_number):
|
||||||
if self.phone:
|
"""Auto-detect and set country from phone number prefix if not already set."""
|
||||||
country = None
|
if not phone_number or self.country_id:
|
||||||
if self.phone.startswith('+'):
|
return
|
||||||
country = self.env['res.country'].search(
|
country_code = get_country_from_phone_number(phone_number)
|
||||||
[('code', '=', get_country_from_phone_number(self.phone))], limit=1
|
if country_code:
|
||||||
)
|
country = self.env["res.country"].search(
|
||||||
if self.country_id:
|
[("code", "=", country_code)], limit=1
|
||||||
country = self.country_id
|
)
|
||||||
if country:
|
if country:
|
||||||
self.phone = self.phone_format(self.phone, country=country)
|
self.country_id = country
|
||||||
|
|
||||||
def format_mobile_from_country(self):
|
@api.onchange("phone", "country_id", "company_id")
|
||||||
if self.mobile:
|
|
||||||
country = None
|
|
||||||
if self.mobile.startswith('+'):
|
|
||||||
country = self.env['res.country'].search(
|
|
||||||
[('code', '=', get_country_from_phone_number(self.mobile))], limit=1
|
|
||||||
)
|
|
||||||
if self.country_id:
|
|
||||||
country = self.country_id
|
|
||||||
if country:
|
|
||||||
self.mobile = self.phone_format(self.mobile, country=country)
|
|
||||||
|
|
||||||
def set_country_from_phone_number(self, phone_number):
|
|
||||||
country = self.env['res.country'].search(
|
|
||||||
[('code', '=', get_country_from_phone_number(phone_number))], limit=1
|
|
||||||
)
|
|
||||||
if country and not self.country_id:
|
|
||||||
self.country_id = country
|
|
||||||
|
|
||||||
@api.onchange('phone')
|
|
||||||
def _onchange_phone_validation(self):
|
def _onchange_phone_validation(self):
|
||||||
# If no country is found, we define the country based on the beginning of the number
|
# Normalize 00xx → +xx before standard processing
|
||||||
if self.phone:
|
if self.phone:
|
||||||
self.phone = self.format_number_zerozero(self.phone)
|
self.phone = self._normalize_phone_number(self.phone)
|
||||||
self.set_country_from_phone_number(self.phone)
|
# Auto-detect country if not set
|
||||||
self.format_phone_from_country()
|
self._set_country_from_phone_number(self.phone)
|
||||||
|
# Let standard module do the formatting
|
||||||
|
super()._onchange_phone_validation()
|
||||||
|
|
||||||
|
@api.onchange("mobile", "country_id", "company_id")
|
||||||
@api.onchange('mobile')
|
|
||||||
def _onchange_mobile_validation(self):
|
def _onchange_mobile_validation(self):
|
||||||
# If no country is found, we define the country based on the beginning of the number
|
# Normalize 00xx → +xx before standard processing
|
||||||
if self.mobile:
|
if self.mobile:
|
||||||
self.mobile = self.format_number_zerozero(self.mobile)
|
self.mobile = self._normalize_phone_number(self.mobile)
|
||||||
self.set_country_from_phone_number(self.mobile)
|
# Auto-detect country if not set
|
||||||
self.format_mobile_from_country()
|
self._set_country_from_phone_number(self.mobile)
|
||||||
|
# Let standard module do the formatting
|
||||||
|
super()._onchange_mobile_validation()
|
||||||
|
|||||||
4
partner_phone_country_validation/tests/__init__.py
Normal file
4
partner_phone_country_validation/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Copyright 2024 Elabore (https://elabore.coop)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
||||||
|
|
||||||
|
from . import test_partner_phone_country_validation
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Copyright 2024 Elabore (https://elabore.coop)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
||||||
|
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.tests import Form, TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestPartnerPhoneCountryValidation(TransactionCase):
|
||||||
|
"""Tests for partner phone country validation module.
|
||||||
|
|
||||||
|
Note on testing @api.onchange methods:
|
||||||
|
--------------------------------------
|
||||||
|
In Odoo, @api.onchange methods are only triggered by the web UI, not by
|
||||||
|
direct field assignment in Python code.
|
||||||
|
|
||||||
|
- `partner.phone = "xxx"` → triggers write() → validates constraints
|
||||||
|
BEFORE any onchange can run → fails if constraint not met
|
||||||
|
|
||||||
|
- `Form(partner)` → simulates web UI behavior → triggers onchange methods
|
||||||
|
when field values change → onchange can set country BEFORE save
|
||||||
|
|
||||||
|
We use Form() for tests that need onchange behavior (auto-detection of
|
||||||
|
country from phone prefix), and direct create/write for constraint tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
|
||||||
|
cls.country_fr = cls.env.ref("base.fr")
|
||||||
|
cls.country_be = cls.env.ref("base.be")
|
||||||
|
# Set company country to France for default formatting
|
||||||
|
cls.env.company.country_id = cls.country_fr
|
||||||
|
|
||||||
|
def test_normalize_00_to_plus(self):
|
||||||
|
"""Test conversion of 00xx format to +xx format."""
|
||||||
|
partner = self.env["res.partner"].create(
|
||||||
|
{"name": "Test Partner", "country_id": self.country_fr.id}
|
||||||
|
)
|
||||||
|
# 00 → + conversion
|
||||||
|
self.assertEqual(
|
||||||
|
partner._normalize_phone_number("0033612345678"), "+33612345678"
|
||||||
|
)
|
||||||
|
# + numbers unchanged
|
||||||
|
self.assertEqual(
|
||||||
|
partner._normalize_phone_number("+33612345678"), "+33612345678"
|
||||||
|
)
|
||||||
|
# National numbers unchanged
|
||||||
|
self.assertEqual(partner._normalize_phone_number("0612345678"), "0612345678")
|
||||||
|
# Edge cases
|
||||||
|
self.assertIsNone(partner._normalize_phone_number(None))
|
||||||
|
self.assertEqual(partner._normalize_phone_number(""), "")
|
||||||
|
|
||||||
|
def test_auto_country_detection(self):
|
||||||
|
"""Test automatic country detection from phone prefix."""
|
||||||
|
partner = self.env["res.partner"].create({"name": "Test Partner"})
|
||||||
|
self.assertFalse(partner.country_id)
|
||||||
|
|
||||||
|
partner._set_country_from_phone_number("+33612345678")
|
||||||
|
self.assertEqual(partner.country_id, self.country_fr)
|
||||||
|
|
||||||
|
def test_auto_country_detection_does_not_override(self):
|
||||||
|
"""Test that auto-detection does not override existing country."""
|
||||||
|
partner = self.env["res.partner"].create(
|
||||||
|
{"name": "Test Partner", "country_id": self.country_be.id}
|
||||||
|
)
|
||||||
|
partner._set_country_from_phone_number("+33612345678")
|
||||||
|
# Country should remain Belgium
|
||||||
|
self.assertEqual(partner.country_id, self.country_be)
|
||||||
|
|
||||||
|
def test_onchange_phone_full_flow(self):
|
||||||
|
"""Test complete onchange flow: 00→+ normalization, country detection, formatting.
|
||||||
|
|
||||||
|
Use Form to simulate UI behavior where onchange triggers before save.
|
||||||
|
"""
|
||||||
|
with Form(self.env["res.partner"]) as partner_form:
|
||||||
|
partner_form.name = "Test Partner"
|
||||||
|
partner_form.phone = "0033612345678"
|
||||||
|
# Onchange should have normalized and detected country
|
||||||
|
self.assertIn("+33", partner_form.phone)
|
||||||
|
self.assertEqual(partner_form.country_id, self.country_fr)
|
||||||
|
|
||||||
|
def test_onchange_mobile_full_flow(self):
|
||||||
|
"""Test complete onchange flow for mobile field."""
|
||||||
|
with Form(self.env["res.partner"]) as partner_form:
|
||||||
|
partner_form.name = "Test Partner"
|
||||||
|
partner_form.mobile = "0032475123456"
|
||||||
|
# Onchange should have normalized and detected country
|
||||||
|
self.assertIn("+32", partner_form.mobile)
|
||||||
|
self.assertEqual(partner_form.country_id, self.country_be)
|
||||||
|
|
||||||
|
def test_onchange_with_existing_country(self):
|
||||||
|
"""Test formatting with existing country uses that country."""
|
||||||
|
with Form(self.env["res.partner"]) as partner_form:
|
||||||
|
partner_form.name = "Test Partner"
|
||||||
|
partner_form.country_id = self.country_fr
|
||||||
|
partner_form.phone = "06 12 34 56 78"
|
||||||
|
# Should be formatted with French prefix
|
||||||
|
self.assertIn("+33", partner_form.phone)
|
||||||
|
|
||||||
|
def test_constraint_country_required_with_phone(self):
|
||||||
|
"""Test that country is required when phone/mobile is set."""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.env["res.partner"].create(
|
||||||
|
{"name": "Test Partner", "phone": "0612345678"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.env["res.partner"].create(
|
||||||
|
{"name": "Test Partner", "mobile": "0612345678"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_constraint_passes_without_phone(self):
|
||||||
|
"""Test that partner without phone doesn't require country."""
|
||||||
|
partner = self.env["res.partner"].create({"name": "Test Partner"})
|
||||||
|
self.assertTrue(partner.id)
|
||||||
|
self.assertFalse(partner.country_id)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCountryFromPhoneNumber(TransactionCase):
|
||||||
|
"""Test the utility function for country detection."""
|
||||||
|
|
||||||
|
def test_valid_international_number(self):
|
||||||
|
"""Test detection from valid international number."""
|
||||||
|
from odoo.addons.partner_phone_country_validation.tools.get_country_from_phone_number import (
|
||||||
|
get_country_from_phone_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(get_country_from_phone_number("+33612345678"), "FR")
|
||||||
|
|
||||||
|
def test_invalid_number_returns_false(self):
|
||||||
|
"""Test that invalid numbers return False."""
|
||||||
|
from odoo.addons.partner_phone_country_validation.tools.get_country_from_phone_number import (
|
||||||
|
get_country_from_phone_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(get_country_from_phone_number("invalid"))
|
||||||
|
self.assertFalse(get_country_from_phone_number("0612345678"))
|
||||||
|
|
||||||
|
def test_national_number_without_prefix(self):
|
||||||
|
"""Test handling of national number without international prefix."""
|
||||||
|
from odoo.addons.partner_phone_country_validation.tools.get_country_from_phone_number import (
|
||||||
|
get_country_from_phone_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
# National numbers without + cannot be reliably detected
|
||||||
|
result = get_country_from_phone_number("0612345678")
|
||||||
|
# phonenumbers cannot determine country without prefix
|
||||||
|
self.assertFalse(result)
|
||||||
Reference in New Issue
Block a user