File size: 6,539 Bytes
c14ceee 0adc1e7 c14ceee | 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 | """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)})
|