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