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