Spaces:
Running
Running
File size: 7,761 Bytes
c308a36 aa870fc c308a36 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | # SPDX-License-Identifier: Apache-2.0
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
"""Odoo ERP connector (open-source) — XML-RPC / JSON-RPC.
Targets Odoo's publicly documented External API shape (SZL's own original client
code, never proprietary SDK code):
- authenticate: common.authenticate(db, login, password, {}) -> uid
- read/list: object.execute_kw(db, uid, pwd, model, 'search_read', [domain], {fields, limit})
- write: object.execute_kw(db, uid, pwd, model, 'create', [vals]) (Λ-gated + receipted)
Models: res.partner (contacts/accounts), crm.lead, sale.order, account.move.
Docs: https://www.odoo.com/documentation/18.0/developer/reference/external_api.html
Default state: CONNECTED vs an SZL self-hosted / public-demo Odoo when
SZL_ODOO_URL/DB/USER/PASSWORD are set; READY (provide credentials) otherwise.
Open-source + free demo DBs / Odoo Online trial exist, so free_tier=True.
"""
from __future__ import annotations
import os
import urllib.request as _ur
from typing import Any
from xml.sax.saxutils import escape as _xe
from ..base import Connector, Records, State, WriteResult, cred_fingerprint, _now
from ..registry import register
from ..governance import gate_write
_UA = "SZL-Connectors/1.0 (odoo xml-rpc)"
def _xmlrpc(url: str, method: str, params: list) -> Any:
"""Minimal XML-RPC client. Returns parsed result or raises.
Hardened against XML-decompression / entity-expansion attacks on the Odoo
response (B411): if defusedxml is present we monkey-patch the stdlib xmlrpc
parser before use. defusedxml is pinned in the Docker image; if it is absent
in a bare runtime we degrade to the stdlib parser rather than crash — the
connector only ever parses responses from an operator-configured Odoo host.
"""
try:
import defusedxml.xmlrpc as _dx
_dx.monkey_patch()
except Exception:
pass
import xmlrpc.client as xc # nosec B411 - parser hardened above via defusedxml.xmlrpc.monkey_patch()
transport = xc.SafeTransport() if url.startswith("https") else xc.Transport()
proxy = xc.ServerProxy(url, transport=transport, allow_none=True)
fn = getattr(proxy, method)
return fn(*params)
@register
class OdooConnector(Connector):
id = "odoo"
label = "Odoo ERP (open-source)"
category = "erp"
auth_kind = "basic"
free_tier = True # open-source; self-host free; public demo DBs / Online trial
writable = True
mcp_tool = "szl_connector_read/write"
env_vars = ["SZL_ODOO_URL", "SZL_ODOO_DB", "SZL_ODOO_USER", "SZL_ODOO_PASSWORD"]
provider_base = "{SZL_ODOO_URL}/xmlrpc/2/{common,object}"
docs_url = "https://www.odoo.com/documentation/18.0/developer/reference/external_api.html"
schema_preview = ["id", "name", "email", "phone", "city", "country_id"]
def _conf(self):
return (os.environ.get("SZL_ODOO_URL"), os.environ.get("SZL_ODOO_DB"),
os.environ.get("SZL_ODOO_USER"), os.environ.get("SZL_ODOO_PASSWORD"))
def _authenticate(self):
url, db, user, pwd = self._conf()
uid = _xmlrpc(f"{url}/xmlrpc/2/common", "authenticate", [db, user, pwd, {}])
return uid, url, db, pwd
def _probe(self):
url, db, user, pwd = self._conf()
if not all([url, db, user, pwd]):
return None, "no creds"
try:
uid = _xmlrpc(f"{url}/xmlrpc/2/common", "authenticate", [db, user, pwd, {}])
return bool(uid), f"Odoo authenticate uid={uid}"
except Exception as e:
return False, f"Odoo authenticate failed: {type(e).__name__}"
def read(self, query: dict | None = None) -> Records:
url, db, user, pwd = self._conf()
missing = [k for k in self.env_vars if not os.environ.get(k)]
if missing:
return self._ready_records(
"provide credentials to activate — set " + ", ".join(missing) +
" (Odoo is open-source; self-host free or use a public demo DB / Online trial)")
model = (query or {}).get("model", "res.partner")
limit = max(1, min(int((query or {}).get("limit", 12)), 50))
fields = (query or {}).get("fields", self.schema_preview)
domain = (query or {}).get("domain", [])
try:
uid = _xmlrpc(f"{url}/xmlrpc/2/common", "authenticate", [db, user, pwd, {}])
if not uid:
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
records=[], source=self.provider_base, live=False,
note="credentials present but Odoo authentication denied",
schema_preview=self.schema_preview)
rows = _xmlrpc(f"{url}/xmlrpc/2/object", "execute_kw",
[db, uid, pwd, model, "search_read", [domain],
{"fields": list(fields), "limit": limit}])
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
records=list(rows), source=f"Odoo XML-RPC {model} @ {url}", live=True,
note=f"live · model={model} · {len(rows)} rows", schema_preview=list(fields))
except Exception as e:
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
records=[], source=self.provider_base, live=False,
note=f"credentials present but Odoo unreachable: {type(e).__name__}",
schema_preview=self.schema_preview)
def write(self, action: dict | None = None) -> WriteResult:
action = action or {}
url, db, user, pwd = self._conf()
connected = all([url, db, user, pwd])
# normalize action shape for the gate (Odoo create)
gate_action = {"method": action.get("method", "create"),
"object": action.get("model", "res.partner"),
"values_keys": sorted((action.get("values") or {}).keys())}
creds_fp = {"password": cred_fingerprint(pwd)} if pwd else {}
allowed, lam, receipt, quorum, detail = gate_write(
connector_id=self.id, connected=connected, action=gate_action,
cred_fingerprints=creds_fp, quorum_present=action.get("quorum_present"))
if not allowed:
return WriteResult(connector_id=self.id, ok=False, state=State.READY if not connected else State.CONNECTED,
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
quorum=quorum, detail=detail, dsse=receipt["dsse"])
try:
uid = _xmlrpc(f"{url}/xmlrpc/2/common", "authenticate", [db, user, pwd, {}])
model = action.get("model", "res.partner")
new_id = _xmlrpc(f"{url}/xmlrpc/2/object", "execute_kw",
[db, uid, pwd, model, action.get("method", "create"),
[action.get("values", {})]])
return WriteResult(connector_id=self.id, ok=True, state=State.CONNECTED,
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
quorum=quorum, detail=f"Odoo {model} created id={new_id}",
dsse=receipt["dsse"])
except Exception as e:
return WriteResult(connector_id=self.id, ok=False, state=State.ERROR,
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
quorum=quorum, detail=f"Odoo write failed: {type(e).__name__}",
dsse=receipt["dsse"])
__all__ = ["OdooConnector"]
|