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