Switch the connector to JSON-RPC; drive records with buttons

JSON-RPC is the recommended Odoo API from 19 on. odoo_connect.py now posts to
/jsonrpc over stdlib urllib instead of xmlrpc.client — same x()/get_or_create()/
fields_named() surface, common.version() becomes version(), server errors raise
OdooError with Odoo's message, 600s timeout so module installs survive.

Also: state changes must go through the model's own button method, never a write
on state. Writing the field skips the delivery order, the journal entries, the
sequence and the validations a hero feature exists to demonstrate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-08-09 02:36:39 +02:00
co-authored by Claude Opus 5
parent d913cdf09a
commit 3fd2c51a3d
4 changed files with 85 additions and 24 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ 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 XML-RPC connector + helpers
scripts/odoo_connect.py JSON-RPC connector + helpers
scripts/build_demo.py config-driven data + hero builder (template)
demo_config.example.json per-client config shape
reference/odoo19-field-gotchas.md version-specific fields + footguns
+32 -6
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 XML-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 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".
---
# Callista Odoo Demo
@@ -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 XML-RPC is minutes of waiting that produces log spew nobody
after both. Installing over JSON-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,9 +129,33 @@ An extending skill may insert its own tier ahead of native — see [Extension po
### Connect
Use `scripts/odoo_connect.py`. Authenticate, print `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` (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.
### Move records with buttons, never with fields
**Every state change goes through the model's own button method** — the same method the UI
button calls — not through `write` on `state` or any other status field:
| Do | Never |
|---|---|
| `x("sale.order", "action_confirm", [ids])` | `write(ids, {"state": "sale"})` |
| `x("account.move", "action_post", [ids])` | `write(ids, {"state": "posted"})` |
| `x("stock.picking", "action_assign", [ids])` then `"button_validate"` | `write(ids, {"state": "done"})` |
| `x("purchase.order", "button_confirm", [ids])` | `write(ids, {"state": "purchase"})` |
| `x("account.move", "button_draft", [ids])` before cancel | `write(ids, {"state": "draft"})` |
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)
— call the wizard's own button rather than treating the dict as success.
### Install modules
@@ -299,6 +323,8 @@ Rules for the boundary:
- **Never invent that a feature works — test it.** *Test heroes* is mandatory.
- **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*).
- **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;
@@ -311,7 +337,7 @@ Rules for the boundary:
## Files
- `scripts/odoo_connect.py`XML-RPC connector + `x()` helper (correct context-kwarg passing).
- `scripts/odoo_connect.py`JSON-RPC connector + `x()` helper (correct context-kwarg passing).
- `scripts/build_demo.py` — config-driven data + hero builder (template to adapt per engagement).
- `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.
+10 -5
View File
@@ -1,6 +1,6 @@
# Odoo 19 (Enterprise) — field names & footguns
Hard-won notes from a real v19 demo build over XML-RPC. Check these before you waste a build cycle. Always confirm the major version first: `common.version()["server_version"]`.
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"]`.
## Renamed core fields (these break v17/v18 code)
| Concept | v17/18 | **v19** |
@@ -11,7 +11,7 @@ Hard-won notes from a real v19 demo build over XML-RPC. Check these before you w
A custom module that sets `category_id` on `res.groups` or `groups_id` on `res.users` will fail to install on v19 with `Invalid field ... / Cannot ...`. Drop the category or use `privilege_id`.
## fields_get returns null attributes over XML-RPC (some v19 builds)
## fields_get returns null attributes over RPC (some v19 builds)
`fields_get([], {"attributes": ["string","type"]})` may return each field with `string=None`. The **keys (technical names) are still correct** — match on keys, not labels. (`fields_named()` in `odoo_connect.py` does this.)
## Product model (v19)
@@ -37,9 +37,14 @@ A custom module that sets `category_id` on `res.groups` or `groups_id` on `res.u
## Stock seeding (so deliveries go Ready)
- Create `stock.quant` with `inventory_quantity` and `context={"inventory_mode": True}`, then call `action_apply_inventory` with the same context. The apply may warn harmlessly if already applied; check by reserving (`stock.picking.action_assign` → state `assigned`).
## XML-RPC helper footgun
## State fields are read-only in practice
- `state` on `sale.order` / `account.move` / `stock.picking` is writable over RPC and doing so is always wrong: it skips the workflow. Confirming an SO with `write` creates no delivery and no invoice basis; posting a move with `write` creates no journal entries and no sequence number.
- Use the button method: `action_confirm`, `action_post` / `button_draft` / `button_cancel`, `action_assign` + `button_validate`, `button_confirm` (PO).
- `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`).
## Module install over XML-RPC
- `x("ir.module.module","button_immediate_install",[ids])`. Heavy — let it run minutes. On a failed XML-data load it rolls back cleanly (module stays uninstalled); fix and retry.
## 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.
- 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.
+42 -12
View File
@@ -1,4 +1,7 @@
"""Generic Odoo XML-RPC connector for odoo-demo-architect.
"""Generic Odoo JSON-RPC connector for odoo-demo-architect.
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.
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)
@@ -7,16 +10,19 @@ Reads credentials from environment variables (or a creds.json next to this file)
ODOO_KEY an API key (Settings > My Profile > Account Security > New API Key)
Usage:
from odoo_connect import x, common, UID, URL, DB
from odoo_connect import x, version, UID, URL, DB
UID # authenticated user id
common.version() # confirm server_version BEFORE building (field names vary by version)
version() # confirm server_version BEFORE building (field names vary by version)
x("res.partner", "search_read", [["customer_rank", ">", 0]], fields=["name"])
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.
"""
import os, json, ssl, xmlrpc.client
import os, json, itertools, urllib.request
_here = os.path.dirname(os.path.abspath(__file__))
@@ -39,18 +45,42 @@ 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.")
_ctx = ssl.create_default_context()
common = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/common", context=_ctx, allow_none=True)
UID = common.authenticate(DB, LOGIN, KEY, {})
_ids = itertools.count(1)
class OdooError(RuntimeError):
"""Server-side error, carrying Odoo's own message + traceback."""
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 version():
"""Server info dict — check ["server_version"] before building anything."""
return _rpc("common", "version", [])
UID = _rpc("common", "authenticate", [DB, LOGIN, KEY, {}])
if not UID:
raise SystemExit("Authentication failed — check DB / LOGIN / API key.")
models = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/object", context=_ctx, allow_none=True)
def x(model, method, *args, **kwargs):
"""execute_kw wrapper. Positional args -> Odoo positional params;
keyword args (limit, fields, context, ...) -> Odoo kwargs."""
return models.execute_kw(DB, UID, KEY, model, method, list(args), kwargs)
return _rpc("object", "execute_kw", [DB, UID, KEY, model, method, list(args), kwargs])
def get_or_create(model, domain, vals, label=None):
@@ -64,13 +94,13 @@ def get_or_create(model, domain, vals, label=None):
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 return null over XML-RPC,
so match on the technical KEYS, which are always reliable."""
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())
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:", common.version().get("server_version"))
print("server_version:", version().get("server_version"))
print("uid:", UID, "url:", URL, "db:", DB)