1 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
16 changed files with 309 additions and 440 deletions

View File

@@ -1,42 +0,0 @@
#!/bin/bash
#
# Run the Python tests of the given Odoo modules.
#
# This script is meant to be executed *inside* an Elabore doodba image
# (docker.0k.io/mirror/odoo:rc_<version>-ELABORE-LIGHT), with a
# PostgreSQL service reachable at $PGHOST. The modules under test are
# read from the current working directory (the checked-out repository),
# which is prepended to the addons path so that the PR's code takes
# precedence over the modules already baked in the image.
#
# Usage: odoo-tests.sh <module1,module2,...>
set -euo pipefail
modules="${1:?usage: odoo-tests.sh <module1,module2,...>}"
export PGHOST=postgres
export PGUSER=odoo
export PGPASSWORD=odoopassword
export PGDATABASE="test_$(basename "$PWD" | tr '.-' '__')"
export WITHOUT_DEMO=all
export DB_FILTER='.*'
# Generate /opt/odoo/auto/odoo.conf from the environment and wait for
# the database to accept connections.
/opt/odoo/common/entrypoint
# Odoo test-tags format: "/module1,/module2" (one leading slash).
tags="/$(echo "$modules" | tr ',' ',/')"
exec odoo \
--config=/opt/odoo/auto/odoo.conf \
--max-cron-threads=0 \
--limit-time-real=10000 \
--workers=0 \
--test-enable \
--stop-after-init \
--addons-path="$PWD,/opt/odoo/auto/addons" \
-d "$PGDATABASE" \
-i "$modules" \
--test-tags "$tags"

View File

@@ -2,6 +2,8 @@ name: pre-commit
on:
pull_request:
branches:
- "16.0*"
jobs:
pre-commit:
@@ -13,10 +15,10 @@ jobs:
python-version: "3.11"
- name: Get python version
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
- uses: https://gitea.com/actions/cache@v3
with:
path: ~/.cache/pre-commit
key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
# - uses: actions/cache@v4
# with:
# path: ~/.cache/pre-commit
# key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit

View File

@@ -1,79 +0,0 @@
name: tests
on:
pull_request:
branches:
- "18.0*"
workflow_dispatch:
jobs:
test:
runs-on: odoo-ci
container:
image: docker.0k.io/mirror/odoo:rc_18.0-ELABORE-LIGHT
services:
postgres:
image: docker.0k.io/postgres:17.2.0-myc
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoopassword
POSTGRES_DB: postgres
steps:
# The doodba image ships no Node.js, so actions/checkout@v4 cannot
# run here. The repository is private, so the job token is required.
- name: Checkout
env:
SECRET_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
token="${GITEA_TOKEN:-${GITHUB_TOKEN:-${SECRET_GITEA_TOKEN:-${SECRET_GITHUB_TOKEN:-}}}}"
if [ -z "$token" ]; then
echo "::error::No job token available to clone the repository"
exit 1
fi
server="${GITHUB_SERVER_URL:-https://git.elabore.coop}"
repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is not set}"
ref="${GITHUB_REF:-${GITHUB_SHA:?neither GITHUB_REF nor GITHUB_SHA is set}}"
auth_url="$(printf '%s' "${server%/}/${repo}.git" |
sed -E "s#^https://#https://${token}@#")"
git init -q .
git config --global --add safe.directory "$PWD"
git remote add origin "$auth_url"
git fetch -q --depth=1 origin "$ref"
git checkout -q FETCH_HEAD
- name: Detect changed modules
id: changed
run: |
set -eu
if [ -n "${GITHUB_BASE_REF:-}" ]; then
git fetch -q --depth=1 origin "refs/heads/$GITHUB_BASE_REF"
base_rev="FETCH_HEAD"
scope="diff against $GITHUB_BASE_REF"
else
base_rev=""
scope="all installable modules (manual run)"
fi
if [ -n "$base_rev" ]; then
modules="$(
git diff --name-only "$base_rev" HEAD |
awk -F/ '{print $1}' | sort -u |
while read -r dir; do
[ -f "$dir/__manifest__.py" ] && echo "$dir"
done | paste -sd, -
)"
else
modules="$(
for dir in */; do
dir="${dir%/}"
[ -f "$dir/__manifest__.py" ] && echo "$dir"
done | paste -sd, -
)"
fi
echo "modules=$modules" >> "$GITHUB_OUTPUT"
echo "Modules to test ($scope): ${modules:-<none>}"
- name: Run Odoo tests
if: steps.changed.outputs.modules != ''
run: bash .gitea/scripts/odoo-tests.sh "${{ steps.changed.outputs.modules }}"

13
.gitignore vendored
View File

@@ -25,19 +25,6 @@ var/
*.egg
*.eggs
# Windows installers
*.msi
# Debian packages
*.deb
# Redhat packages
*.rpm
# MacOS packages
*.dmg
*.pkg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt

View File

@@ -22,7 +22,7 @@ exclude: |
(LICENSE.*|COPYING.*)
default_language_version:
python: python3
node: "22.9.0"
node: "16.17.0"
repos:
- repo: local
hooks:
@@ -38,12 +38,8 @@ repos:
entry: found a en.po file
language: fail
files: '[a-zA-Z0-9_]*/i18n/en\.po$'
- repo: https://github.com/sbidoul/whool
rev: v1.3
hooks:
- id: whool-init
- repo: https://github.com/oca/maintainer-tools
rev: b89f767503be6ab2b11e4f50a7557cb20066e667
rev: f9b919b9868143135a9c9cb03021089cabba8223
hooks:
# update the NOT INSTALLABLE ADDONS section above
- id: oca-update-pre-commit-excluded-addons
@@ -53,13 +49,16 @@ repos:
$(git rev-parse --show-toplevel))"'
- id: oca-gen-addon-readme
entry:
bash -c 'oca-gen-addon-readme --addons-dir=. --branch=$(git symbolic-ref
bash -c 'oca-gen-addon-readme
--addons-dir=.
--branch=$(git symbolic-ref
refs/remotes/origin/HEAD | sed "s@^refs/remotes/origin/@@")
--repo-name=$(basename $(git rev-parse --show-toplevel)) --org-name="Elabore"
--if-source-changed --keep-source-digest --convert-fragments-to-markdown'
- id: oca-gen-external-dependencies
--repo-name=$(basename $(git rev-parse --show-toplevel))
--org-name="Elabore"
--if-source-changed --keep-source-digest'
- repo: https://github.com/OCA/odoo-pre-commit-hooks
rev: v0.0.33
rev: v0.1.4
hooks:
- id: oca-checks-odoo-module
- id: oca-checks-po
@@ -78,8 +77,8 @@ repos:
files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
language: node
additional_dependencies:
- "prettier@3.3.3"
- "@prettier/plugin-xml@3.4.1"
- "prettier@2.7.1"
- "@prettier/plugin-xml@2.2.0"
- repo: local
hooks:
- id: eslint
@@ -92,11 +91,10 @@ repos:
types: [javascript]
language: node
additional_dependencies:
- "eslint@9.12.0"
- "eslint-plugin-jsdoc@50.3.1"
- "globals@16.0.0"
- "eslint@8.24.0"
- "eslint-plugin-jsdoc@"
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
# exclude autogenerated files
@@ -117,8 +115,23 @@ repos:
- id: check-xml
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: https://github.com/PyCQA/docformatter
rev: v1.7.7
hooks:
- id: docformatter
args: [
"--in-place", # modify the files
"--recursive", # run on all the files
"--wrap-summaries",
"88", # max length of 1st line
"--wrap-descriptions",
"88", # max length of other lines
"--pre-summary-newline", # new line before a long summary
"--make-summary-multi-line", # force summary on multilines
]
additional_dependencies: ["tomli"] # if Python <3.11
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8
rev: v0.12.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]

View File

@@ -10,7 +10,7 @@ manifest-required-authors=Elabore
manifest-required-keys=license
manifest-deprecated-keys=description,active
license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3
valid-odoo-versions=18.0
valid-odoo-versions=16.0
[MESSAGES CONTROL]
disable=all
@@ -25,25 +25,19 @@ disable=all
enable=anomalous-backslash-in-string,
api-one-deprecated,
api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv,
duplicate-key,
duplicate-xml-fields,
duplicate-xml-record-id,
eval-referenced,
incoherent-interpreter-exec-perm,
openerp-exception-warning,
redundant-modulename-xml,
relative-import,
rst-syntax-error,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
assignment-from-none,
attribute-deprecated,
dangerous-default-value,
development-status-allowed,
duplicate-key,
eval-used,
incoherent-interpreter-exec-perm,
license-allowed,
manifest-author-string,
manifest-deprecated-key,
@@ -54,68 +48,73 @@ enable=anomalous-backslash-in-string,
method-inverse,
method-required-super,
method-search,
openerp-exception-warning,
pointless-statement,
pointless-string-statement,
print-used,
redundant-keyword-arg,
redundant-modulename-xml,
reimported,
relative-import,
return-in-init,
rst-syntax-error,
sql-injection,
too-few-format-args,
translation-field,
translation-required,
unreachable,
use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password,
dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node,
deprecated-openerp-xml-node,
duplicate-po-message-definition,
file-not-used,
missing-newline-extrafiles,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
str-format-used,
unnecessary-utf8-coding-comment,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
attribute-string-redundant,
consider-merging-classes-inherited,
context-overridden,
except-pass,
file-not-used,
invalid-commit,
manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme,
missing-return,
odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter,
resource-not-exist,
str-format-used,
test-folder-imported,
translation-contains-variable,
translation-positional-used,
website-manifest-key-not-valid-uri,
external-request-timeout,
missing-manifest-dependency,
too-complex,,
create-user-wo-reset-password,
dangerous-filter-wo-user,
file-not-used,
missing-newline-extrafiles,
no-utf8-coding-comment,
old-api7-method-defined,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout,
# messages that do not cause the lint step to fail
consider-merging-classes-inherited,
create-user-wo-reset-password,
dangerous-filter-wo-user,
deprecated-module,
file-not-used,
invalid-commit,
missing-manifest-dependency,
missing-newline-extrafiles,
missing-readme,
no-utf8-coding-comment,
odoo-addons-relative-import,
old-api7-method-defined,
redefined-builtin,
manifest-external-assets
too-complex,
unnecessary-utf8-coding-comment
[REPORTS]

View File

@@ -9,7 +9,7 @@ manifest-required-authors=Elabore
manifest-required-keys=license
manifest-deprecated-keys=description,active
license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3
valid-odoo-versions=18.0
valid-odoo-versions=16.0
[MESSAGES CONTROL]
disable=all
@@ -17,25 +17,19 @@ disable=all
enable=anomalous-backslash-in-string,
api-one-deprecated,
api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv,
duplicate-key,
duplicate-xml-fields,
duplicate-xml-record-id,
eval-referenced,
incoherent-interpreter-exec-perm,
openerp-exception-warning,
redundant-modulename-xml,
relative-import,
rst-syntax-error,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
assignment-from-none,
attribute-deprecated,
dangerous-default-value,
development-status-allowed,
duplicate-key,
eval-used,
incoherent-interpreter-exec-perm,
license-allowed,
manifest-author-string,
manifest-deprecated-key,
@@ -46,50 +40,56 @@ enable=anomalous-backslash-in-string,
method-inverse,
method-required-super,
method-search,
openerp-exception-warning,
pointless-statement,
pointless-string-statement,
print-used,
redundant-keyword-arg,
redundant-modulename-xml,
reimported,
relative-import,
return-in-init,
rst-syntax-error,
sql-injection,
too-few-format-args,
translation-field,
translation-required,
unreachable,
use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password,
dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node,
deprecated-openerp-xml-node,
duplicate-po-message-definition,
file-not-used,
missing-newline-extrafiles,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
str-format-used,
unnecessary-utf8-coding-comment,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
attribute-string-redundant,
consider-merging-classes-inherited,
context-overridden,
except-pass,
file-not-used,
invalid-commit,
manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme,
missing-return,
odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter,
resource-not-exist,
str-format-used,
test-folder-imported,
translation-contains-variable,
translation-positional-used,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout
[REPORTS]

View File

@@ -1,205 +0,0 @@
var globals = require('globals');
jsdoc = require("eslint-plugin-jsdoc");
const config = [{
plugins: {
jsdoc,
},
languageOptions: {
globals: {
_: "readonly",
$: "readonly",
fuzzy: "readonly",
jQuery: "readonly",
moment: "readonly",
odoo: "readonly",
openerp: "readonly",
owl: "readonly",
luxon: "readonly",
QUnit: "readonly",
...globals.browser,
},
ecmaVersion: 2024,
sourceType: "script",
},
rules: {
"accessor-pairs": "warn",
"array-callback-return": "warn",
"callback-return": "warn",
"capitalized-comments": ["warn", "always", {
ignoreConsecutiveComments: true,
ignoreInlineComments: true,
}],
complexity: ["warn", 15],
"constructor-super": "warn",
"dot-notation": "warn",
eqeqeq: "warn",
"global-require": "warn",
"handle-callback-err": "warn",
"id-blacklist": "warn",
"id-match": "warn",
"init-declarations": "error",
"max-depth": "warn",
"max-nested-callbacks": "warn",
"max-statements-per-line": "warn",
"no-alert": "warn",
"no-array-constructor": "warn",
"no-caller": "warn",
"no-case-declarations": "warn",
"no-class-assign": "warn",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-debugger": "error",
"no-delete-var": "warn",
"no-div-regex": "warn",
"no-dupe-args": "error",
"no-dupe-class-members": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-duplicate-imports": "error",
"no-else-return": "warn",
"no-empty-character-class": "warn",
"no-empty-function": "error",
"no-empty-pattern": "error",
"no-empty": "warn",
"no-eq-null": "error",
"no-eval": "error",
"no-ex-assign": "error",
"no-extend-native": "warn",
"no-extra-bind": "warn",
"no-extra-boolean-cast": "warn",
"no-extra-label": "warn",
"no-fallthrough": "warn",
"no-func-assign": "error",
"no-global-assign": "error",
"no-implicit-coercion": ["warn", {
allow: ["~"],
}],
"no-implicit-globals": "warn",
"no-implied-eval": "warn",
"no-inline-comments": "warn",
"no-inner-declarations": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-iterator": "warn",
"no-label-var": "warn",
"no-labels": "warn",
"no-lone-blocks": "warn",
"no-lonely-if": "error",
"no-mixed-requires": "error",
"no-multi-str": "warn",
"no-native-reassign": "error",
"no-negated-condition": "warn",
"no-negated-in-lhs": "error",
"no-new-func": "warn",
"no-new-object": "warn",
"no-new-require": "warn",
"no-new-symbol": "warn",
"no-new-wrappers": "warn",
"no-new": "warn",
"no-obj-calls": "warn",
"no-octal-escape": "warn",
"no-octal": "warn",
"no-param-reassign": "warn",
"no-path-concat": "warn",
"no-process-env": "warn",
"no-process-exit": "warn",
"no-proto": "warn",
"no-prototype-builtins": "warn",
"no-redeclare": "warn",
"no-regex-spaces": "warn",
"no-restricted-globals": "warn",
"no-restricted-imports": "warn",
"no-restricted-modules": "warn",
"no-restricted-syntax": "warn",
"no-return-assign": "error",
"no-script-url": "warn",
"no-self-assign": "warn",
"no-self-compare": "warn",
"no-sequences": "warn",
"no-shadow-restricted-names": "warn",
"no-shadow": "warn",
"no-sparse-arrays": "warn",
"no-sync": "warn",
"no-this-before-super": "warn",
"no-throw-literal": "warn",
"no-undef-init": "warn",
"no-undef": "error",
"no-unmodified-loop-condition": "warn",
"no-unneeded-ternary": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unused-expressions": "error",
"no-unused-labels": "error",
"no-unused-vars": "error",
"no-use-before-define": "error",
"no-useless-call": "warn",
"no-useless-computed-key": "warn",
"no-useless-concat": "warn",
"no-useless-constructor": "warn",
"no-useless-escape": "warn",
"no-useless-rename": "warn",
"no-void": "warn",
"no-with": "warn",
"operator-assignment": ["error", "always"],
"prefer-const": "warn",
radix: "warn",
"require-yield": "warn",
"sort-imports": "warn",
"spaced-comment": ["error", "always"],
strict: ["error", "function"],
"use-isnan": "error",
"jsdoc/check-tag-names": "warn",
"jsdoc/check-types": "warn",
"jsdoc/require-param-description": "off",
"jsdoc/require-return": "off",
"jsdoc/require-return-description": "off",
"jsdoc/require-return-type": "off",
"valid-typeof": "warn",
yoda: "warn",
},
settings: {
jsdoc: {
tagNamePreference: {
arg: "param",
argument: "param",
augments: "extends",
constructor: "class",
exception: "throws",
func: "function",
method: "function",
prop: "property",
return: "returns",
virtual: "abstract",
yield: "yields",
},
preferredTypes: {
array: "Array",
bool: "Boolean",
boolean: "Boolean",
number: "Number",
object: "Object",
str: "String",
string: "String",
},
},
},
}, {
files: ["**/*.esm.js", "**/*test.js"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module",
},
}];
module.exports = config

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,14 +0,0 @@
/** @type {import('prettier').Config} */
const config = {
// https://github.com/prettier/prettier/issues/15388#issuecomment-1717746872
plugins: [require.resolve("@prettier/plugin-xml")],
bracketSpacing: false,
printWidth: 88,
proseWrap: "always",
semi: true,
trailingComma: "es5",
xmlWhitespaceSensitivity: "preserve",
};
module.exports = config;