8 Commits

Author SHA1 Message Date
Stéphan Sainléger
e3fa6b8d6e [FIX] partner_geolocalize_usability: avoid Nominatim HTTP 429 rejections
The stock ``base_geocoder._call_openstreetmap`` sends a generic Odoo
``User-Agent`` shared across thousands of instances, which Nominatim
rate-limits and blocks (HTTP 429 "Too many requests"), so mass
geolocation fails regardless of client-side throttling.

Override ``_call_openstreetmap`` to:

- send a deployment-specific ``User-Agent`` read from a new system
  parameter ``partner_geolocalize_usability.user_agent`` (shipped with an
  obvious placeholder so an unconfigured instance fails loudly instead of
  silently reusing the banned generic agent);
- throttle requests to one per second (``time.sleep``) before each call,
  complying with the Nominatim usage policy even when callers loop over
  many partners;
- return ``None`` instead of raising ``IndexError`` when Nominatim
  returns an empty result for an unknown address.

Document the required system parameter in the README and add tests
covering the User-Agent selection, throttling and empty/blank results.
2026-07-21 17:23:43 +02:00
Stéphan Sainléger
e161f431ec [CLN] partner_portal_extra_links: move Elabore specific add-on to a private repo 2026-06-22 21:43:02 +02:00
Stéphan Sainléger
8640e2eefa [IMP] partner_portal_extra_links: add buttons in portal /my/home 2026-06-22 21:41:49 +02:00
Stéphan Sainléger
a213def5a1 [IMP] partner_portal_extra_link: fix dependency and clean a view 2026-06-22 21:40:01 +02:00
Stéphan Sainléger
c8d6b8f6c4 [MIG] partner_portal_extra_links: migrate to 18.0 2026-04-02 17:03:04 +02:00
Stéphan Sainléger
6e776cbe64 [IMP] partner_phone_country_validation: add tests 2026-03-06 10:25:37 +01:00
Stéphan Sainléger
0c7120cd68 [IMP] partner_phone_country_validation: complete README 2026-03-06 10:25:24 +01:00
Stéphan Sainléger
946b69e722 [FIX] partner_phone_country_validation: correct overall fragile process 2026-03-06 10:24:51 +01:00
12 changed files with 524 additions and 76 deletions

View File

@@ -21,7 +21,25 @@ It also modify the partner form vieww for a better understanding of the geolocat
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
======================

View File

@@ -17,10 +17,11 @@
],
"qweb": [],
"external_dependencies": {
"python": [],
"python": ["requests"],
},
# always loaded
"data": [
"data/ir_config_parameter.xml",
"views/res_partner.xml",
],
# only loaded in demonstration mode

View 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>

View File

@@ -1,2 +1,3 @@
from . import base_geocoder
from . import res_partner

View 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"])

View File

@@ -0,0 +1 @@
from . import test_base_geocoder

View 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()

View File

@@ -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
============
Use Odoo normal module installation procedure to install
``partner_phone_country_validation``.
1. Install the ``base_phone`` module from the OCA ``connector-telephony`` repository
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
===========
Bugs are tracked on `our issues website <https://github.com/elabore-coop/partner_phone_country_validation/issues>`_. In case of
trouble, please check there if your issue has already been
reported. If you spotted it first, help us smashing it by providing a
detailed and welcomed feedback.
Bugs are tracked on `Elabore Git Issues <https://git.elabore.coop/Elabore/partner-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smash it by providing a detailed and welcomed
feedback.
Credits
=======
Authors
-------
* `Elabore <https://elabore.coop>`_
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
-------
The development of this module has been financially supported by:
* Elabore (https://elabore.coop)
* `Elabore <https://elabore.coop>`_
Maintainer
----------
.. image:: https://elabore.coop/logo.png
:alt: Elabore
:target: https://elabore.coop
This module is maintained by Elabore.

View File

@@ -1,73 +1,54 @@
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
import logging
_logger = logging.getLogger(__name__)
class Partner(models.Model):
_name = 'res.partner'
_inherit = ['res.partner', 'phone.validation.mixin']
_name = "res.partner"
_inherit = ["res.partner", "phone.validation.mixin"]
@api.constrains('country_id','phone','mobile')
@api.constrains("country_id", "phone", "mobile")
def _check_country_id(self):
if not self.country_id and (self.phone or self.mobile):
raise ValidationError(_('You must set a country for the phone number'))
for partner in self:
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 _onchange_country(self):
self.format_mobile_from_country()
self.format_phone_from_country()
def format_number_zerozero(self, number):
if number.startswith("00"):
number = "+"+number[2:]
def _normalize_phone_number(self, number):
"""Convert 00xx format to +xx international format."""
if number and number.startswith("00"):
return "+" + number[2:]
return number
def format_phone_from_country(self):
if self.phone:
country = None
if self.phone.startswith('+'):
country = self.env['res.country'].search(
[('code', '=', get_country_from_phone_number(self.phone))], limit=1
def _set_country_from_phone_number(self, phone_number):
"""Auto-detect and set country from phone number prefix if not already set."""
if not phone_number or self.country_id:
return
country_code = get_country_from_phone_number(phone_number)
if country_code:
country = self.env["res.country"].search(
[("code", "=", country_code)], limit=1
)
if self.country_id:
country = self.country_id
if country:
self.phone = self.phone_format(self.phone, country=country)
def format_mobile_from_country(self):
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')
@api.onchange("phone", "country_id", "company_id")
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:
self.phone = self.format_number_zerozero(self.phone)
self.set_country_from_phone_number(self.phone)
self.format_phone_from_country()
self.phone = self._normalize_phone_number(self.phone)
# Auto-detect country if not set
self._set_country_from_phone_number(self.phone)
# Let standard module do the formatting
super()._onchange_phone_validation()
@api.onchange('mobile')
@api.onchange("mobile", "country_id", "company_id")
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:
self.mobile = self.format_number_zerozero(self.mobile)
self.set_country_from_phone_number(self.mobile)
self.format_mobile_from_country()
self.mobile = self._normalize_phone_number(self.mobile)
# Auto-detect country if not set
self._set_country_from_phone_number(self.mobile)
# Let standard module do the formatting
super()._onchange_mobile_validation()

View 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

View File

@@ -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)