|
|
|
@@ -0,0 +1,115 @@
|
|
|
|
|
"""Odoo client abstraction.
|
|
|
|
|
|
|
|
|
|
Cetana never spreads raw Odoo calls through the codebase. Everything goes
|
|
|
|
|
through this client behind an interface. The concrete client is chosen at
|
|
|
|
|
runtime from the dynamic settings store (apps.core.settings_service), so the
|
|
|
|
|
integration can be configured and switched from the admin panel without a
|
|
|
|
|
deploy — with a mock fallback when Odoo is not configured.
|
|
|
|
|
"""
|
|
|
|
|
import xmlrpc.client
|
|
|
|
|
from typing import Protocol
|
|
|
|
|
|
|
|
|
|
from apps.core.settings_service import SettingsService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OdooClientInterface(Protocol):
|
|
|
|
|
def search_partner(self, key: str, value) -> dict | None: ...
|
|
|
|
|
def create_partner(self, payload: dict) -> dict: ...
|
|
|
|
|
def create_invoice(self, payload: dict) -> dict: ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MockOdooClient:
|
|
|
|
|
"""Deterministic in-process stand-in used when Odoo is not configured."""
|
|
|
|
|
|
|
|
|
|
def search_partner(self, key: str, value) -> dict | None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def create_partner(self, payload: dict) -> dict:
|
|
|
|
|
return {"id": f"MOCK-PARTNER-{abs(hash(str(payload))) % 10_000_000}"}
|
|
|
|
|
|
|
|
|
|
def create_invoice(self, payload: dict) -> dict:
|
|
|
|
|
return {"id": f"MOCK-INV-{abs(hash(str(payload))) % 10_000_000}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OdooXmlRpcClient:
|
|
|
|
|
"""Real Odoo client over the standard XML-RPC API (stdlib only)."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, url, db, username, password="", api_key="", auth_method="password"):
|
|
|
|
|
self.url = (url or "").rstrip("/")
|
|
|
|
|
self.db = db
|
|
|
|
|
self.username = username
|
|
|
|
|
self.password = password
|
|
|
|
|
self.api_key = api_key
|
|
|
|
|
self.auth_method = auth_method
|
|
|
|
|
self._uid = None
|
|
|
|
|
|
|
|
|
|
def _authenticate(self):
|
|
|
|
|
common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
|
|
|
|
|
secret = self.api_key if self.auth_method == "api_key" else self.password
|
|
|
|
|
self._uid = common.authenticate(self.db, self.username, secret, {})
|
|
|
|
|
return self._uid
|
|
|
|
|
|
|
|
|
|
def _execute(self, model, method, method_args, kwargs=None):
|
|
|
|
|
if self._uid is None:
|
|
|
|
|
self._authenticate()
|
|
|
|
|
secret = self.api_key if self.auth_method == "api_key" else self.password
|
|
|
|
|
models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object")
|
|
|
|
|
return models.execute_kw(self.db, self._uid, secret, model, method, method_args, kwargs or {})
|
|
|
|
|
|
|
|
|
|
def search_partner(self, key: str, value) -> dict | None:
|
|
|
|
|
results = self._execute(
|
|
|
|
|
"res.partner", "search_read",
|
|
|
|
|
[[[key, "=", value]]],
|
|
|
|
|
{"limit": 1, "fields": ["id", "name"]},
|
|
|
|
|
)
|
|
|
|
|
return results[0] if results else None
|
|
|
|
|
|
|
|
|
|
def create_partner(self, payload: dict) -> dict:
|
|
|
|
|
partner_id = self._execute("res.partner", "create", [payload])
|
|
|
|
|
return {"id": partner_id}
|
|
|
|
|
|
|
|
|
|
def create_invoice(self, payload: dict) -> dict:
|
|
|
|
|
ref = payload.get("partner_ref") or ""
|
|
|
|
|
partner = None
|
|
|
|
|
if ref:
|
|
|
|
|
partner = self.search_partner("ref", ref) or self.search_partner("name", ref)
|
|
|
|
|
if partner is None and ref:
|
|
|
|
|
partner = {"id": self._execute("res.partner", "create", [{"name": ref}])}
|
|
|
|
|
vals = {
|
|
|
|
|
"move_type": "out_invoice",
|
|
|
|
|
"ref": payload.get("reference"),
|
|
|
|
|
"partner_id": partner["id"] if partner else False,
|
|
|
|
|
"invoice_line_ids": [(0, 0, {
|
|
|
|
|
"name": payload.get("reference") or "Processing",
|
|
|
|
|
"quantity": 1,
|
|
|
|
|
"price_unit": float(payload.get("amount") or 0),
|
|
|
|
|
})],
|
|
|
|
|
}
|
|
|
|
|
invoice_id = self._execute("account.move", "create", [vals])
|
|
|
|
|
return {"id": invoice_id}
|
|
|
|
|
|
|
|
|
|
def test_connection(self) -> dict:
|
|
|
|
|
"""Validate reachability + credentials. Returns server version and uid."""
|
|
|
|
|
common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
|
|
|
|
|
version = common.version()
|
|
|
|
|
uid = self._authenticate()
|
|
|
|
|
if not uid:
|
|
|
|
|
raise ValueError("Authentication failed — check database, username and secret.")
|
|
|
|
|
return {"version": version, "uid": uid}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_odoo_client() -> OdooClientInterface:
|
|
|
|
|
service = SettingsService()
|
|
|
|
|
if not service.get_bool("odoo.enabled", False):
|
|
|
|
|
return MockOdooClient()
|
|
|
|
|
url = service.get("odoo.url", "")
|
|
|
|
|
if not url:
|
|
|
|
|
return MockOdooClient()
|
|
|
|
|
return OdooXmlRpcClient(
|
|
|
|
|
url=url,
|
|
|
|
|
db=service.get("odoo.database", ""),
|
|
|
|
|
username=service.get("odoo.username", ""),
|
|
|
|
|
password=service.get("odoo.password", ""),
|
|
|
|
|
api_key=service.get("odoo.api_key", ""),
|
|
|
|
|
auth_method=service.get("odoo.auth_method", "password"),
|
|
|
|
|
)
|