Files
callista-odoo-demo/scripts/odoo_connect.py
T
glenn schrooyenandClaude Opus 5 93edf9296e 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>
2026-08-09 02:53:03 +02:00

154 lines
6.4 KiB
Python

"""Generic Odoo JSON-2 connector for callista-odoo-demo.
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 (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
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=...)
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, urllib.request, urllib.error
_here = os.path.dirname(os.path.abspath(__file__))
def _cfg(key, default=None):
v = os.environ.get(key)
if v:
return v
path = os.path.join(_here, "creds.json")
if os.path.exists(path):
with open(path) as f:
return json.load(f).get(key, default)
return default
URL = (_cfg("ODOO_URL") or "").rstrip("/")
DB = _cfg("ODOO_DB")
KEY = _cfg("ODOO_KEY")
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.")
_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. `.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 _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 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)
# 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 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.
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=vals)
return ids[0]
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 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("version:", version().get("version"))
print("uid:", UID, "url:", URL, "db:", DB or "(from host)")