"""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)})