loopable / platform /odoo_client.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
0adc1e7 verified
Raw
History Blame Contribute Delete
6.54 kB
"""Read-only Odoo XML-RPC client for the FFS/RI analytics dashboard.
Credentials come from environment variables (local .env OR Hugging Face Secrets):
ODOO_URL, ODOO_DB, ODOO_USER, ODOO_API_KEY
READ_ONLY is hard-enforced: any write/create/delete/action method is blocked.
This is a defense-in-depth measure on top of using a read-only Odoo user.
"""
import os
import http.client
import threading
import xmlrpc.client
try:
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).parent / '.env')
except Exception:
pass # On HF, env comes from Secrets β€” no .env file needed.
WRITE_METHODS = {
'write', 'create', 'unlink', 'copy', 'load', 'import_data', 'execute',
}
WRITE_PREFIXES = ('write_', 'create_', 'unlink_', 'delete_', 'remove_', 'update_',
'set_', 'do_', 'action_', 'button_', 'process_', 'send_',
'cancel_', 'confirm_', 'approve_', 'validate_', 'commit_', 'apply_')
class ReadOnlyError(RuntimeError):
pass
# stdlib ServerProxy has NO timeout: a stalled socket hangs a worker forever (one cause of the
# transport-break hangs the reconnect logic below papers over). Generous default β€” this is a
# hang-guard, not a performance knob; big ledger pulls legitimately take a while.
_RPC_TIMEOUT = float(os.environ.get('ODOO_RPC_TIMEOUT', '180'))
class _TimeoutSafeTransport(xmlrpc.client.SafeTransport):
def make_connection(self, host):
conn = super().make_connection(host)
conn.timeout = _RPC_TIMEOUT
return conn
class _TimeoutTransport(xmlrpc.client.Transport):
def make_connection(self, host):
conn = super().make_connection(host)
conn.timeout = _RPC_TIMEOUT
return conn
def _proxy(uri):
tr = _TimeoutSafeTransport() if uri.startswith('https') else _TimeoutTransport()
return xmlrpc.client.ServerProxy(uri, transport=tr)
class OdooClient:
READ_ONLY = True
def __init__(self, creds=None):
"""`creds` (R3 keychain cutover): an explicit {url, db, user, api_key} dict β€” a TENANT'S
stored credentials β€” wins over the environment. None keeps the env/.env behavior every
existing caller relies on (tenant #0's connection)."""
creds = creds or {}
self.url = str(creds.get('url') or os.environ.get('ODOO_URL', '')).rstrip('/')
self.db = str(creds.get('db') or os.environ.get('ODOO_DB', ''))
self.user = str(creds.get('user') or os.environ.get('ODOO_USER', ''))
self.api_key = str(creds.get('api_key') or os.environ.get('ODOO_API_KEY', ''))
missing = [k for k, v in [('ODOO_URL', self.url), ('ODOO_DB', self.db),
('ODOO_USER', self.user), ('ODOO_API_KEY', self.api_key)] if not v]
if missing:
raise RuntimeError(f"Missing Odoo env vars: {', '.join(missing)}")
# ONE in-flight XML-RPC call per client. `ServerProxy` keeps a single persistent HTTP
# connection, and stdlib http.client is NOT thread-safe on one connection: two threads
# (FastAPI's sync-route threadpool, a background cache refresh, Streamlit's own threads)
# interleaving on it raise CannotSendRequest/ResponseNotReady and each collision costs a
# reconnect β€” churn that reads as random multi-second lag. Serialising the transport is
# correct and cheap: Odoo calls are the slow path and the caches keep them rare.
self._xlock = threading.RLock()
self._connect()
def _connect(self, _retried=False):
"""(Re)establish the XML-RPC connection. Called on init and to recover a broken socket.
uid is cached for the client's lifetime (authenticate is just a uid lookup β€” never
re-fire it per request). One retry on a transient connect failure (WinError 10060 etc.)
so a momentary network blip during prewarm doesn't error a whole module."""
try:
common = _proxy(f'{self.url}/xmlrpc/2/common')
self.uid = common.authenticate(self.db, self.user, self.api_key, {})
except (http.client.HTTPException, ConnectionError, OSError):
if _retried:
raise
import time as _time
_time.sleep(3)
return self._connect(_retried=True)
if not self.uid:
raise RuntimeError("Odoo authentication failed β€” check credentials.")
self.models = _proxy(f'{self.url}/xmlrpc/2/object')
def _guard(self, method):
if self.READ_ONLY and (method in WRITE_METHODS or any(method.startswith(p) for p in WRITE_PREFIXES)):
raise ReadOnlyError(f"BLOCKED write op '{method}' β€” dashboard is read-only.")
def execute_kw(self, model, method, args, kwargs=None):
self._guard(method)
with self._xlock:
try:
return self.models.execute_kw(self.db, self.uid, self.api_key, model, method, args, kwargs or {})
except (http.client.HTTPException, ConnectionError, OSError) as e:
# transient transport breakage (CannotSendRequest / broken pipe / reset) leaves the
# persistent socket dead β€” reconnect once and retry. xmlrpc Faults are NOT caught here,
# so genuine Odoo errors still propagate.
try:
self._connect()
except Exception:
raise e
return self.models.execute_kw(self.db, self.uid, self.api_key, model, method, args, kwargs or {})
def search_read(self, model, domain=None, fields=None, limit=None, offset=0, order=None):
kwargs = {'fields': fields or [], 'offset': offset}
if limit is not None:
kwargs['limit'] = limit
if order:
kwargs['order'] = order
return self.execute_kw(model, 'search_read', [domain or []], kwargs)
def search_count(self, model, domain=None):
return self.execute_kw(model, 'search_count', [domain or []])
def read_group(self, model, domain=None, fields=None, groupby=None, lazy=False, orderby=None, limit=None):
kwargs = {'fields': fields or [], 'groupby': groupby or [], 'lazy': lazy}
if orderby:
kwargs['orderby'] = orderby
if limit is not None:
kwargs['limit'] = limit
return self.execute_kw(model, 'read_group', [domain or []], kwargs)
def fields_get(self, model, attributes=('string', 'type')):
return self.execute_kw(model, 'fields_get', [], {'attributes': list(attributes)})