Use the real Odoo 19 external API (JSON-2), and batch calls

The previous commit moved to /jsonrpc, which the v19 docs list as deprecated
alongside /xmlrpc — both go away in Odoo 22. The actual v19 external API is
JSON-2: POST /json/2/<model>/<method>, API key as a bearer token.

Three consequences, all of which change calling code:
  - no uid and no password; UID now comes from res.users/context_get
  - every argument is named, positional args do not exist, so x() becomes
    x(model, method, ids=None, **kwargs) and callers pass domain=/vals=/fields=
  - one call is one transaction and nothing chains, which is the mechanical
    reason state changes must go through a button method

Batching: no multi-call envelope exists, so batching means widening a call, not
bundling calls. Added get_or_create_many() (one search_read + one create for a
whole set) and put build_demo.py's product, customer, vehicle, invoice and
headroom loops through set-based calls.

test_odoo_connect.py checks the call shape and the batching offline, with a
faked urlopen — none of this can be exercised without a live instance otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-08-09 02:53:03 +02:00
co-authored by Claude Opus 5
parent 3fd2c51a3d
commit 93edf9296e
6 changed files with 295 additions and 129 deletions
+104 -57
View File
@@ -1,28 +1,34 @@
"""Generic Odoo JSON-RPC connector for odoo-demo-architect.
"""Generic Odoo JSON-2 connector for callista-odoo-demo.
JSON-RPC (`POST /jsonrpc`) is the recommended API from Odoo 19 on; XML-RPC still
works but is legacy. Same call surface either way — only the transport changed.
Odoo 19's external API is **JSON-2**: `POST /json/2/<model>/<method>`, authenticated
with an API key as a bearer token. The old `/xmlrpc`, `/xmlrpc/2` and `/jsonrpc`
endpoints (services common / db / object) are all deprecated and scheduled for removal
in Odoo 22. https://www.odoo.com/documentation/19.0/developer/reference/external_api.html
Three things differ from execute_kw, and they bite:
1. No uid, no password. The API key alone identifies the user.
2. **Every argument is named** — there are no positional args. Record ids go in `ids`,
everything else under the ORM method's own parameter name (`domain`, `fields`,
`vals`, ...). Your database's exact signatures are at <ODOO_URL>/doc.
3. Each call is its own SQL transaction. Multi-step work must go through ONE method
that does it all (`action_confirm`, `search_read`) — see SKILL.md.
Reads credentials from environment variables (or a creds.json next to this file):
ODOO_URL e.g. https://my-instance.odoo.com (no trailing slash)
ODOO_DB database name
ODOO_LOGIN user login (often an email; "admin" on Odoo.sh trials)
ODOO_DB database name (only needed when one domain serves several databases)
ODOO_KEY an API key (Settings > My Profile > Account Security > New API Key)
Usage:
from odoo_connect import x, version, UID, URL, DB
UID # authenticated user id
version() # confirm server_version BEFORE building (field names vary by version)
x("res.partner", "search_read", [["customer_rank", ">", 0]], fields=["name"])
version()["version"] # confirm BEFORE building — fields vary by version
x("res.partner", "search", domain=[["customer_rank", ">", 0]])
x("res.partner", "read", ids, fields=["name"])
x("sale.order", "action_confirm", ids) # buttons, never write(state=...)
The x() helper passes keyword args straight into execute_kw's kwargs, so pass
Odoo kwargs natively: x(model, "search", domain, limit=5, context={"active_test": False})
Do NOT pass a context dict positionally — that becomes `offset` and Postgres errors.
Move records through their BUTTONS, not their fields: x("sale.order",
"action_confirm", [ids]) — never write state="sale". See SKILL.md.
Note: keys expire (three months maximum, one day recommended for interactive use), and
Odoo Online restricts the external API to Custom plans — not One App Free or Standard.
"""
import os, json, itertools, urllib.request
import os, json, urllib.request, urllib.error
_here = os.path.dirname(os.path.abspath(__file__))
@@ -36,71 +42,112 @@ def _cfg(key, default=None):
return json.load(f).get(key, default)
return default
URL = (_cfg("ODOO_URL") or "").rstrip("/")
DB = _cfg("ODOO_DB")
LOGIN = _cfg("ODOO_LOGIN") or "admin"
KEY = _cfg("ODOO_KEY")
URL = (_cfg("ODOO_URL") or "").rstrip("/")
DB = _cfg("ODOO_DB")
KEY = _cfg("ODOO_KEY")
if not (URL and DB and KEY):
raise SystemExit("Set ODOO_URL, ODOO_DB, ODOO_KEY (and optionally ODOO_LOGIN) "
"as env vars or in creds.json next to this script.")
if not (URL and KEY):
raise SystemExit("Set ODOO_URL and ODOO_KEY (and ODOO_DB if the domain serves several "
"databases) as env vars or in creds.json next to this script.")
_ids = itertools.count(1)
_HEADERS = {"Authorization": f"bearer {KEY}",
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "callista-odoo-demo python-urllib"}
if DB:
_HEADERS["X-Odoo-Database"] = DB
class OdooError(RuntimeError):
"""Server-side error, carrying Odoo's own message + traceback."""
"""Server-side error. `.name` is the Python exception class, `.debug` the traceback."""
def __init__(self, payload):
self.name = payload.get("name", "UnknownError")
self.debug = payload.get("debug", "")
super().__init__(f"{self.name}: {payload.get('message', '')}".strip())
def _rpc(service, method, args, timeout=600):
"""One JSON-RPC call. Module installs take minutes — hence the long default timeout."""
payload = json.dumps({"jsonrpc": "2.0", "method": "call", "id": next(_ids),
"params": {"service": service, "method": method, "args": args}})
req = urllib.request.Request(f"{URL}/jsonrpc", data=payload.encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
res = json.load(r)
if "error" in res:
e = res["error"]
d = e.get("data") or {}
raise OdooError(d.get("message") or e.get("message") or json.dumps(e)[:500])
return res["result"]
def _post(path, body, timeout):
req = urllib.request.Request(f"{URL}{path}", data=json.dumps(body).encode(),
headers=_HEADERS, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
except urllib.error.HTTPError as e:
raw = e.read()
try:
raise OdooError(json.loads(raw)) from None
except json.JSONDecodeError:
raise OdooError({"name": f"HTTP {e.code}", "message": raw[:500].decode(errors="replace")}) from None
def x(model, method, ids=None, timeout=600, **kwargs):
"""Call `method` on `model` over JSON-2.
ids : record id or list of ids. Omit for @api.model methods (search, create).
kwargs : every other argument, BY NAME — domain=, fields=, vals=, context=, ...
timeout : seconds; module installs legitimately take minutes.
"""
body = dict(kwargs)
if ids is not None:
body["ids"] = ids if isinstance(ids, list) else [ids]
return _post(f"/json/2/{model}/{method}", body, timeout)
def version():
"""Server info dict — check ["server_version"] before building anything."""
return _rpc("common", "version", [])
"""Server version — {"version_info": [19, 0, ...], "version": "19.0"}."""
req = urllib.request.Request(f"{URL}/web/version")
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
UID = _rpc("common", "authenticate", [DB, LOGIN, KEY, {}])
# JSON-2 has no authenticate(); the key identifies the user. context_get hands back its uid.
UID = x("res.users", "context_get").get("uid")
if not UID:
raise SystemExit("Authentication failed — check DB / LOGIN / API key.")
def x(model, method, *args, **kwargs):
"""execute_kw wrapper. Positional args -> Odoo positional params;
keyword args (limit, fields, context, ...) -> Odoo kwargs."""
return _rpc("object", "execute_kw", [DB, UID, KEY, model, method, list(args), kwargs])
raise SystemExit("Authentication failed — check ODOO_URL / ODOO_KEY (keys expire).")
def get_or_create(model, domain, vals, label=None):
"""Idempotent create: update if a record matches `domain`, else create."""
ids = x(model, "search", domain, limit=1)
"""Idempotent create: update if a record matches `domain`, else create.
Two round trips per record — for more than a handful use get_or_create_many()."""
ids = x(model, "search", domain=domain, limit=1)
if ids:
x(model, "write", ids, vals)
x(model, "write", ids, vals=vals)
return ids[0]
return x(model, "create", vals)
return x(model, "create", vals_list=vals)
def get_or_create_many(model, key_field, rows):
"""Batched get-or-create. `rows` is a list of vals dicts, each carrying `key_field`.
One search_read for the whole set + one create for everything missing, instead of
two calls per record. Returns {key_value: id}.
"""
rows = [r for r in rows if r.get(key_field)]
if not rows:
return {}
keys = [r[key_field] for r in rows]
found = {r[key_field]: r["id"] for r in
x(model, "search_read", domain=[[key_field, "in", keys]], fields=[key_field])}
existing = [r for r in rows if r[key_field] in found]
missing = [r for r in rows if r[key_field] not in found]
if missing:
ids = x(model, "create", vals_list=missing)
found.update(zip([r[key_field] for r in missing], ids))
# ponytail: refreshing existing rows stays one call per record — differing vals can't be
# batched into a single write. Only costs on re-runs; batch it if a config ever gets big.
for r in existing:
x(model, "write", found[r[key_field]], vals=r)
return found
def fields_named(model, *needles):
"""Return field technical names on `model` whose name/label contains any needle.
NOTE: on some Odoo 19 builds fields_get attribute VALUES come back null, so match
on the technical KEYS, which are always reliable."""
keys = list(x(model, "fields_get", [], {}).keys())
"""Return field technical names on `model` whose name contains any needle.
NOTE: on some v19 builds fields_get attribute VALUES come back null, so match on the
technical KEYS, which are always reliable."""
keys = list(x(model, "fields_get").keys())
nl = [n.lower() for n in needles]
return sorted(k for k in keys if any(n in k.lower() for n in nl))
if __name__ == "__main__":
print("server_version:", version().get("server_version"))
print("uid:", UID, "url:", URL, "db:", DB)
print("version:", version().get("version"))
print("uid:", UID, "url:", URL, "db:", DB or "(from host)")