diff --git a/README.md b/README.md index c6d7f79..9131139 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SKILL.md b/SKILL.md index 134e84e..932a4f8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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 ", "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 ", "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. diff --git a/reference/odoo19-field-gotchas.md b/reference/odoo19-field-gotchas.md index bcab272..93d5447 100644 --- a/reference/odoo19-field-gotchas.md +++ b/reference/odoo19-field-gotchas.md @@ -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. diff --git a/scripts/odoo_connect.py b/scripts/odoo_connect.py index ef0ba78..e193641 100644 --- a/scripts/odoo_connect.py +++ b/scripts/odoo_connect.py @@ -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)