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
+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