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:
co-authored by
Claude Opus 5
parent
d913cdf09a
commit
3fd2c51a3d
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user