diff --git a/README.md b/README.md index 9131139..3368957 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SKILL.md b/SKILL.md index 932a4f8..ddc09c2 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 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". +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 ", "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//`, 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 `/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 `/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). diff --git a/reference/odoo19-field-gotchas.md b/reference/odoo19-field-gotchas.md index 93d5447..44fec87 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 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//`, `Authorization: bearer `). `/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 `/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. diff --git a/scripts/build_demo.py b/scripts/build_demo.py index 53c6c78..83e2d87 100644 --- a/scripts/build_demo.py +++ b/scripts/build_demo.py @@ -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) diff --git a/scripts/odoo_connect.py b/scripts/odoo_connect.py index e193641..9b17aca 100644 --- a/scripts/odoo_connect.py +++ b/scripts/odoo_connect.py @@ -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//`, 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 /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)") diff --git a/scripts/test_odoo_connect.py b/scripts/test_odoo_connect.py new file mode 100644 index 0000000..823dca1 --- /dev/null +++ b/scripts/test_odoo_connect.py @@ -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")