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
+7 -4
View File
@@ -44,9 +44,11 @@ same-named fork silently shadows upstream depending on install order.
## Quickstart
1. Put your Odoo creds in env vars (or `scripts/creds.json`):
`ODOO_URL`, `ODOO_DB`, `ODOO_LOGIN`, `ODOO_KEY`.
2. Verify the connection: `python scripts/odoo_connect.py` → prints `server_version`.
1. Put your Odoo creds in env vars (or `scripts/creds.json`): `ODOO_URL`, `ODOO_KEY`, and
`ODOO_DB` if one domain serves several databases. JSON-2 authenticates on the API key
alone — there is no login/password, so no `ODOO_LOGIN`.
2. Verify the connection: `python scripts/odoo_connect.py` → prints the server version and uid.
(`python scripts/test_odoo_connect.py` checks the call shape offline, no server needed.)
3. Copy `demo_config.example.json``scripts/demo_config.json`, fill it from the brief.
4. Ask Claude to run the pipeline (see [`SKILL.md`](SKILL.md)).
@@ -61,8 +63,9 @@ Several core fields were renamed in v19 (`res.users.group_ids`, `res.groups` los
```
SKILL.md the procedure (what the agent follows)
UPSTREAM.md fork point + divergence from upstream
scripts/odoo_connect.py JSON-RPC connector + helpers
scripts/odoo_connect.py JSON-2 connector + helpers
scripts/build_demo.py config-driven data + hero builder (template)
scripts/test_odoo_connect.py offline self-check of the JSON-2 call shape
demo_config.example.json per-client config shape
reference/odoo19-field-gotchas.md version-specific fields + footguns
templates/ brand.css + the doc templates
+58 -10
View File
@@ -1,6 +1,6 @@
---
name: callista-odoo-demo
description: Build a complete, client-tailored Odoo demo end to end — assembles the discovery brief from any source (transcript, notes, email, Knowcap, or a live interview), researches native-vs-custom per hero feature, stands up a LIVE Odoo database over JSON-RPC (modules + realistic data + working heroes), tests it, and generates the sales docs and the post-demo follow-through set (objection sheet, scoped quote, timeline, SOW). Use when prepping an Odoo sales demo, POC, or proof-of-value, or when writing the follow-up after one. Triggers: "build an Odoo demo", "demo DB for <client>", "Odoo proof of concept", "stand up a demo before the meeting", "write the follow-up after the demo".
description: Build a complete, client-tailored Odoo demo end to end — assembles the discovery brief from any source (transcript, notes, email, Knowcap, or a live interview), researches native-vs-custom per hero feature, stands up a LIVE Odoo database over the Odoo 19 JSON-2 API (modules + realistic data + working heroes), tests it, and generates the sales docs and the post-demo follow-through set (objection sheet, scoped quote, timeline, SOW). Use when prepping an Odoo sales demo, POC, or proof-of-value, or when writing the follow-up after one. Triggers: "build an Odoo demo", "demo DB for <client>", "Odoo proof of concept", "stand up a demo before the meeting", "write the follow-up after the demo".
---
# Callista Odoo Demo
@@ -73,7 +73,7 @@ Everything else is supporting flow.
Odoo apps in scope, plus any vertical modules routed by an extending skill.
## Odoo instance
URL, database, login, API key. Ask if absent — do not guess.
URL, database, API key (JSON-2 needs no login). Ask if absent — do not guess.
UI language + `l10n_*` localisation to install.
## Demo audience
@@ -96,7 +96,7 @@ casually, because extending skills point at these names.
*Instance prep* and *client research* are independent: the install list comes from the
brief (plus any routing overlay), while research feeds hero *building*, which happens
after both. Installing over JSON-RPC is minutes of waiting that produces log spew nobody
after both. Installing over RPC is minutes of waiting that produces log spew nobody
needs in context. Run them as two parallel subagents launched in **one** message:
**Agent 1 — instance prep.** Give it the Odoo credentials and the module list. It sets
@@ -129,10 +129,51 @@ An extending skill may insert its own tier ahead of native — see [Extension po
### Connect
Use `scripts/odoo_connect.py` (JSON-RPC — the recommended API from Odoo 19 on). Authenticate,
print `version()["server_version"]` to confirm the major version (field names differ across
versions — see `reference/odoo19-field-gotchas.md`). All build steps go through the
`x(model, method, *args, **kwargs)` helper.
Use `scripts/odoo_connect.py`. It speaks **JSON-2** (`POST /json/2/<model>/<method>`, API key
as a bearer token) — Odoo 19's external API. `/xmlrpc`, `/xmlrpc/2` and `/jsonrpc` are all
deprecated and disappear in Odoo 22; do not write execute_kw calls.
Print `version()["version"]` to confirm the major version (field names differ across versions
— see `reference/odoo19-field-gotchas.md`). All build steps go through the
`x(model, method, ids=None, **kwargs)` helper. Three JSON-2 facts to build around:
- **Every argument is named. There are no positional arguments.** Record ids go in `ids`;
everything else goes under the ORM method's own parameter name —
`x("res.partner", "search", domain=[...], limit=5)`,
`x("res.partner", "write", ids, vals={...})`, `x(model, "create", vals_list={...})`.
Guessing the parameter name is the new version-assumption trap: the database publishes its
own signatures at `<ODOO_URL>/doc` — read it rather than guess.
- **No uid, no password.** The API key identifies the user; `UID` comes from
`res.users/context_get`. Keys expire — three months maximum, and a demo built on a
one-day key stops working the morning of the demo.
- **Each call is its own SQL transaction**, committed on success, discarded on error. Nothing
chains. This is exactly why the next step exists.
Odoo Online serves the external API on **Custom** plans only — not One App Free, not Standard.
Check this before promising a live build on a client's own trial instance.
### Batch every call you can
**One call over N records, never N calls over one record.** Odoo's ORM methods are all
set-based; the round trip is the expensive part, and a build that loops is the difference
between a demo standing up in one minute and in twenty.
| Loop of N calls | The one call that replaces it |
|---|---|
| `create` per record | `create(vals_list=[{...}, {...}, ...])` → list of ids, in order |
| `write` per record, same values | `write(ids, vals={...})` — ids is a list |
| `search` then `read` | `search_read(domain=..., fields=[...])` — also one transaction, so no TOCTOU |
| `read` per id | `read(ids, fields=[...])` |
| `action_confirm` / `action_post` per record | the same button over the whole id list |
| get-or-create per record | `get_or_create_many(model, key_field, rows)` — one search_read + one create |
There is **no multi-call batch envelope** in JSON-2 — you cannot pack unrelated calls into
one HTTP request, and each call commits separately. Batching means widening a call, not
bundling calls. Where records genuinely differ (different `vals` per record), a loop is
correct; keep it and move on.
Two places not to batch: anything whose per-record failure you need to isolate (a batched
`create` fails as a unit, so one bad row loses the set), and the live demo click itself.
### Move records with buttons, never with fields
@@ -147,14 +188,19 @@ button calls — not through `write` on `state` or any other status field:
| `x("purchase.order", "button_confirm", [ids])` | `write(ids, {"state": "purchase"})` |
| `x("account.move", "button_draft", [ids])` before cancel | `write(ids, {"state": "draft"})` |
This is also the only way to stay consistent: every JSON-2 call is a separate transaction, so
a hand-rolled sequence of writes can be interrupted halfway by a concurrent change and leave a
half-confirmed record. The button method does the whole thing in one transaction — all of it
commits, or none of it does.
Writing the field skips everything the button does — no delivery order, no journal entries,
no sequence number, no stock moves — so the record *looks* confirmed and every downstream
screen in the demo is empty. It also silently skips the very validations a hero feature is
meant to demonstrate.
Don't guess a method name: `fields_named()` finds fields, but for methods read the button off
the form view (`x(model, "get_views", [[[False, "form"]]], ...)`) or check the model in the
docs. `button_validate` on a picking may return a wizard dict (immediate transfer, backorder)
the form view (`x(model, "get_views", views=[[False, "form"]])`) or read the database's own
signatures at `<ODOO_URL>/doc`. `button_validate` on a picking may return a wizard dict (immediate transfer, backorder)
— call the wizard's own button rather than treating the dict as success.
### Install modules
@@ -325,6 +371,7 @@ Rules for the boundary:
- **Research before building.** Version assumptions are the #1 source of wasted effort.
- **Buttons, not fields.** Never advance a record's state with `write` — call the button
method (see *Move records with buttons, never with fields*).
- **Batch.** One call over N records, never N calls over one (see *Batch every call you can*).
- **Don't hard-code the client.** Everything client-specific lives in `demo_config.json`.
- **Currency before invoices. Weight before fleet. Pre-stage before the live click.** Order matters.
- Never hand over an environment holding test records. Purge them and read back the counts;
@@ -337,8 +384,9 @@ Rules for the boundary:
## Files
- `scripts/odoo_connect.py` — JSON-RPC connector + `x()` helper (correct context-kwarg passing).
- `scripts/odoo_connect.py` — JSON-2 connector + `x()` helper (named-argument call shape).
- `scripts/build_demo.py` — config-driven data + hero builder (template to adapt per engagement).
- `scripts/test_odoo_connect.py` — offline self-check: JSON-2 call shape + batched get-or-create.
- `demo_config.example.json` — the shape of a per-client config.
- `reference/odoo19-field-gotchas.md` — version-specific field names + footguns learned the hard way.
- `templates/` — doc templates (brand-themed, `{{placeholder}}` driven).
+9 -3
View File
@@ -1,6 +1,6 @@
# Odoo 19 (Enterprise) — field names & footguns
Hard-won notes from a real v19 demo build over JSON-RPC. Check these before you waste a build cycle. Always confirm the major version first: `version()["server_version"]`.
Hard-won notes from a real v19 demo build. Check these before you waste a build cycle. Always confirm the major version first: `version()["version"]`.
## Renamed core fields (these break v17/v18 code)
| Concept | v17/18 | **v19** |
@@ -43,8 +43,14 @@ A custom module that sets `category_id` on `res.groups` or `groups_id` on `res.u
- `stock.picking.button_validate` can return a wizard dict (`stock.backorder.confirmation`, immediate transfer) instead of `True` — create the wizard from `res_model`/`context` and call its `process` button.
## RPC helper footgun
- `execute_kw(..., method, args, kwargs)`: a **context dict must go in kwargs**, never as a positional arg. `search(domain, {"active_test":False})` sends the dict as `offset``psycopg2 can't adapt type 'dict'`. Pass `context=` as a keyword (see `x()` in `odoo_connect.py`).
- v19's API is **JSON-2** (`POST /json/2/<model>/<method>`, `Authorization: bearer <api key>`). `/xmlrpc`, `/xmlrpc/2` and `/jsonrpc` still work but are deprecated and go away in Odoo 22.
- **No positional arguments exist.** ids go in the `ids` body key, everything else under the ORM parameter's own name: `domain`, `fields`, `vals` (write), `vals_list` (create), `context`, `limit`. Wrong name = `TypeError` from the server, not a silent misread — but guessing still costs a round trip. Every model+method signature for YOUR database is published at `<ODOO_URL>/doc`.
- The old execute_kw footgun (a context dict passed positionally landing in `offset``psycopg2 can't adapt type 'dict'`) is gone with the endpoint. If you see it, you are on legacy code.
- **One call, one transaction.** Committed on success, discarded on error, and nothing chains across calls. Prefer a single method that does the whole job (`search_read`, `action_confirm`) over a sequence you orchestrate client-side.
- API keys expire — three months maximum, one day if you generated it interactively. Check the expiry before demo day.
- Odoo Online exposes the external API on **Custom** plans only (not One App Free, not Standard).
- **Batch by widening calls, not by bundling them.** JSON-2 has no multi-call envelope, but every ORM method is set-based: `create(vals_list=[...])` takes a list and returns ids in order, `write`/`read`/`action_post` take an id list, `search_read` replaces search+read in one transaction. `get_or_create_many()` in `odoo_connect.py` does the batched get-or-create. A batched `create` fails as a unit — keep the loop where you need per-record error isolation.
## Module install over RPC
- `x("ir.module.module","button_immediate_install",[ids])`. Heavy — let it run minutes (the JSON-RPC connector uses a 600s timeout for this reason). On a failed XML-data load it rolls back cleanly (module stays uninstalled); fix and retry.
- `x("ir.module.module","button_immediate_install", ids)`. Heavy — let it run minutes (`x()` defaults to a 600s timeout for this reason). On a failed XML-data load it rolls back cleanly (module stays uninstalled); fix and retry.
- After deploying a custom module to git (Odoo.sh), wait for the rebuild, then `x("ir.module.module","update_list")` and search for it before installing.
+49 -55
View File
@@ -9,7 +9,7 @@ Idempotent: get-or-create by name. Run order matters (currency before invoices).
Requires odoo_connect.py in the same folder + creds (env or creds.json).
"""
import json, os
from odoo_connect import x, get_or_create, URL, DB
from odoo_connect import x, get_or_create, get_or_create_many, OdooError, URL, DB
CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "demo_config.json")
cfg = json.load(open(CFG, encoding="utf-8"))
@@ -35,91 +35,85 @@ if unknown:
# 1. currency (must run before invoices)
cur = cfg.get("currency")
if cur:
ids = x("res.currency", "search", [["name", "=", cur]], context={"active_test": False})
ids = x("res.currency", "search", domain=[["name", "=", cur]], context={"active_test": False})
if not ids:
ids = [x("res.currency", "create", {"name": cur, "rounding": 0.01})]
x("res.currency", "write", ids, {"active": True})
comp = x("res.company", "search", [], limit=1)[0]
ids = [x("res.currency", "create", vals_list={"name": cur, "rounding": 0.01})]
x("res.currency", "write", ids, vals={"active": True})
comp = x("res.company", "search", domain=[], limit=1)[0]
try:
x("res.company", "write", [comp], {"currency_id": ids[0]})
x("res.company", "write", comp, vals={"currency_id": ids[0]})
except Exception as e:
print(" ! currency switch blocked (invoices exist?):", str(e)[:120])
print("currency:", cur)
# 2. settings
s = x("res.config.settings", "create", {
s = x("res.config.settings", "create", vals_list={
"account_use_credit_limit": bool(cfg.get("use_credit_limit", True)),
"group_product_pricelist": True,
})
x("res.config.settings", "execute", [s])
x("res.config.settings", "execute", s)
# 3. products
prod = {}
for p in cfg.get("products", []):
vals = {"name": p["name"], "list_price": p.get("price", 0.0),
"weight": p.get("weight", 0.0), "type": "consu", "is_storable": True}
try:
prod[p["name"]] = get_or_create("product.template", [["name", "=", p["name"]]], vals)
except Exception:
vals.pop("is_storable", None)
prod[p["name"]] = get_or_create("product.template", [["name", "=", p["name"]]], vals)
# 3. products (batched: one search_read + one create for the whole catalogue)
rows = [{"name": p["name"], "list_price": p.get("price", 0.0), "weight": p.get("weight", 0.0),
"type": "consu", "is_storable": True} for p in cfg.get("products", [])]
try:
prod = get_or_create_many("product.template", "name", rows)
except OdooError:
for r in rows: # older builds have no is_storable — retry the batch without it
r.pop("is_storable", None)
prod = get_or_create_many("product.template", "name", rows)
print("products:", len(prod))
# 4. vehicle categories + vehicles
brand = None
if cfg.get("vehicles"):
# 4. vehicle categories + vehicles — batched one level at a time (each level needs the ids above it)
veh = cfg.get("vehicles", [])
if veh:
brand = get_or_create("fleet.vehicle.model.brand", [["name", "=", "Demo Fleet"]], {"name": "Demo Fleet"})
for v in cfg.get("vehicles", []):
cat = get_or_create("fleet.vehicle.model.category", [["name", "=", v["category"]]],
{"name": v["category"], "weight_capacity": v.get("capacity_kg", 0.0)})
model = get_or_create("fleet.vehicle.model", [["name", "=", v["model"]]],
{"name": v["model"], "brand_id": brand, "category_id": cat})
get_or_create("fleet.vehicle", [["license_plate", "=", v["plate"]]],
{"model_id": model, "license_plate": v["plate"], "category_id": cat})
print("vehicles:", len(cfg.get("vehicles", [])))
cats = get_or_create_many("fleet.vehicle.model.category", "name",
[{"name": v["category"], "weight_capacity": v.get("capacity_kg", 0.0)} for v in veh])
models = get_or_create_many("fleet.vehicle.model", "name",
[{"name": v["model"], "brand_id": brand, "category_id": cats[v["category"]]} for v in veh])
get_or_create_many("fleet.vehicle", "license_plate",
[{"license_plate": v["plate"], "model_id": models[v["model"]],
"category_id": cats[v["category"]]} for v in veh])
print("vehicles:", len(veh))
# 5. customers with credit limits
cust = {}
for c in cfg.get("customers", []):
cust[c["name"]] = get_or_create("res.partner", [["name", "=", c["name"]]],
{"name": c["name"], "is_company": True, "customer_rank": 1,
"use_partner_credit_limit": True, "credit_limit": c.get("credit_limit", 0.0)})
# 5. customers with credit limits (batched)
cust = get_or_create_many("res.partner", "name",
[{"name": c["name"], "is_company": True, "customer_rank": 1, "use_partner_credit_limit": True,
"credit_limit": c.get("credit_limit", 0.0)} for c in cfg.get("customers", [])])
print("customers:", len(cust))
# 6. B2B pricelist (optional global % discount)
pl_cfg = cfg.get("pricelist")
if pl_cfg:
pl = get_or_create("product.pricelist", [["name", "=", pl_cfg["name"]]], {"name": pl_cfg["name"]})
if pl_cfg.get("percent") and not x("product.pricelist.item", "search", [["pricelist_id", "=", pl]]):
x("product.pricelist.item", "create", {"pricelist_id": pl, "applied_on": "3_global",
if pl_cfg.get("percent") and not x("product.pricelist.item", "search", domain=[["pricelist_id", "=", pl]]):
x("product.pricelist.item", "create", vals_list={"pricelist_id": pl, "applied_on": "3_global",
"compute_price": "percentage", "percent_price": pl_cfg["percent"]})
for cname in pl_cfg.get("assign_to", []):
if cname in cust:
x("res.partner", "write", [cust[cname]], {"property_product_pricelist": pl})
assign = [cust[c] for c in pl_cfg.get("assign_to", []) if c in cust]
if assign: # same vals for all of them -> one write
x("res.partner", "write", assign, vals={"property_product_pricelist": pl})
# 7. pre-stage receivables (post unpaid invoices)
def first_variant(tmpl_id):
return x("product.product", "search", [["product_tmpl_id", "=", tmpl_id]], limit=1)[0]
return x("product.product", "search", domain=[["product_tmpl_id", "=", tmpl_id]], limit=1)[0]
any_prod = first_variant(next(iter(prod.values()))) if prod else None
for stage in cfg.get("prestage_receivables", []):
cid = cust.get(stage["customer"])
if not cid:
continue
mv = x("account.move", "create", {
"move_type": "out_invoice", "partner_id": cid,
stages = [st for st in cfg.get("prestage_receivables", []) if cust.get(st["customer"])]
if stages:
mvs = x("account.move", "create", vals_list=[{
"move_type": "out_invoice", "partner_id": cust[st["customer"]],
"invoice_line_ids": [(0, 0, {"product_id": any_prod, "quantity": 1,
"price_unit": stage["amount"], "tax_ids": [(6, 0, [])]})]})
"price_unit": st["amount"], "tax_ids": [(6, 0, [])]})]}
for st in stages])
try:
x("account.move", "action_post", [mv])
except Exception as e:
print(" ! post for", stage["customer"], str(e)[:100])
x("account.move", "action_post", mvs) # one button call over every invoice
except OdooError as e:
print(" ! posting invoices:", str(e)[:160])
# 8. verify credit headroom
print("\n-- credit headroom --")
for c in cfg.get("customers", []):
cid = cust[c["name"]]
r = x("res.partner", "read", [cid], ["credit", "credit_limit"])[0]
for r in x("res.partner", "read", list(cust.values()), fields=["name", "credit", "credit_limit"]):
flag = "OVER" if r["credit"] > r["credit_limit"] else "ok"
print(f" {c['name']:30} owes={r['credit']:>12,.0f} limit={r['credit_limit']:>12,.0f} {flag}")
print(f" {r['name']:30} owes={r['credit']:>12,.0f} limit={r['credit_limit']:>12,.0f} {flag}")
print("\nDONE. Instance:", URL, "DB:", DB)
+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)")
+68
View File
@@ -0,0 +1,68 @@
"""Offline self-check for odoo_connect: JSON-2 call shape + batched get-or-create.
No server, no framework: fakes urlopen and asserts on the requests that come out.
python scripts/test_odoo_connect.py
"""
import io, json, os, sys, urllib.request
os.environ.update(ODOO_URL="https://demo.example.com", ODOO_DB="demo", ODOO_KEY="testkey")
CALLS = [] # (path, body) of every request made
REPLIES = {} # path -> value to return
class _Resp(io.BytesIO):
def __enter__(self): return self
def __exit__(self, *a): return False
def _fake_urlopen(req, timeout=None):
body = json.loads(req.data) if req.data else None
CALLS.append((req.full_url.split("demo.example.com")[1], body))
return _Resp(json.dumps(REPLIES.get(CALLS[-1][0], [])).encode())
urllib.request.urlopen = _fake_urlopen
REPLIES["/json/2/res.users/context_get"] = {"lang": "en_US", "uid": 7}
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from odoo_connect import x, get_or_create_many # noqa: E402 (must import after the fake)
import odoo_connect
assert odoo_connect.UID == 7, "uid comes from res.users/context_get, not from authenticate()"
# --- call shape: model+method in the URL, ids in the body, everything else named ---
CALLS.clear()
x("sale.order", "action_confirm", 42, context={"lang": "nl_BE"})
path, body = CALLS[0]
assert path == "/json/2/sale.order/action_confirm", path
assert body == {"ids": [42], "context": {"lang": "nl_BE"}}, body # bare id wrapped into a list
CALLS.clear()
x("res.partner", "search", domain=[["is_company", "=", True]], limit=5)
assert CALLS[0][1] == {"domain": [["is_company", "=", True]], "limit": 5}, CALLS[0][1]
assert "ids" not in CALLS[0][1], "@api.model methods must not send ids"
# --- batched get-or-create: one search_read + one create, whatever the row count ---
REPLIES["/json/2/res.partner/search_read"] = [{"id": 3, "name": "Existing NV"}]
REPLIES["/json/2/res.partner/create"] = [11, 12]
CALLS.clear()
out = get_or_create_many("res.partner", "name", [
{"name": "Existing NV", "credit_limit": 100},
{"name": "New One BV", "credit_limit": 200},
{"name": "New Two BV", "credit_limit": 300},
])
assert out == {"Existing NV": 3, "New One BV": 11, "New Two BV": 12}, out
paths = [p for p, _ in CALLS]
assert paths.count("/json/2/res.partner/search_read") == 1, paths
assert paths.count("/json/2/res.partner/create") == 1, "the two new rows must go in ONE create"
created = dict(CALLS)["/json/2/res.partner/create"]["vals_list"]
assert [r["name"] for r in created] == ["New One BV", "New Two BV"], created
assert paths.count("/json/2/res.partner/write") == 1, "only the pre-existing row is rewritten"
# nothing to do -> no round trip at all
CALLS.clear()
assert get_or_create_many("res.partner", "name", []) == {}
assert not CALLS
print("ok — JSON-2 call shape and batching hold")