From c50459755ab2289cef536ee56b2ed2bdb68b9af9 Mon Sep 17 00:00:00 2001 From: Claude AI Date: Thu, 29 Jan 2026 22:19:03 +0000 Subject: [PATCH] feat(integrations): add Odoo XML-RPC client Co-Authored-By: Claude Opus 4.5 --- services/integrations/app/odoo/__init__.py | 18 ++ services/integrations/app/odoo/client.py | 167 +++++++++++++++++++ services/integrations/app/odoo/exceptions.py | 23 +++ 3 files changed, 208 insertions(+) create mode 100644 services/integrations/app/odoo/__init__.py create mode 100644 services/integrations/app/odoo/client.py create mode 100644 services/integrations/app/odoo/exceptions.py diff --git a/services/integrations/app/odoo/__init__.py b/services/integrations/app/odoo/__init__.py new file mode 100644 index 0000000..92ba5fe --- /dev/null +++ b/services/integrations/app/odoo/__init__.py @@ -0,0 +1,18 @@ +from app.odoo.client import OdooClient, get_odoo_client +from app.odoo.exceptions import ( + OdooError, + OdooConnectionError, + OdooAuthError, + OdooNotFoundError, + OdooValidationError, +) + +__all__ = [ + "OdooClient", + "get_odoo_client", + "OdooError", + "OdooConnectionError", + "OdooAuthError", + "OdooNotFoundError", + "OdooValidationError", +] diff --git a/services/integrations/app/odoo/client.py b/services/integrations/app/odoo/client.py new file mode 100644 index 0000000..31ac70d --- /dev/null +++ b/services/integrations/app/odoo/client.py @@ -0,0 +1,167 @@ +import xmlrpc.client +from typing import Any, Optional +from functools import lru_cache + +from app.config import get_settings +from app.odoo.exceptions import ( + OdooConnectionError, + OdooAuthError, + OdooNotFoundError, + OdooValidationError, + OdooError, +) + +settings = get_settings() + + +class OdooClient: + """XML-RPC client for Odoo""" + + def __init__( + self, + url: str = None, + db: str = None, + user: str = None, + api_key: str = None, + ): + self.url = url or settings.ODOO_URL + self.db = db or settings.ODOO_DB + self.user = user or settings.ODOO_USER + self.api_key = api_key or settings.ODOO_API_KEY + self._uid: Optional[int] = None + self._common = None + self._models = None + + def _get_common(self): + if not self._common: + try: + self._common = xmlrpc.client.ServerProxy( + f"{self.url}/xmlrpc/2/common", + allow_none=True, + ) + except Exception as e: + raise OdooConnectionError(f"Failed to connect: {e}") + return self._common + + def _get_models(self): + if not self._models: + try: + self._models = xmlrpc.client.ServerProxy( + f"{self.url}/xmlrpc/2/object", + allow_none=True, + ) + except Exception as e: + raise OdooConnectionError(f"Failed to connect: {e}") + return self._models + + def authenticate(self) -> int: + """Authenticate and return user ID""" + if self._uid: + return self._uid + + if not all([self.url, self.db, self.user, self.api_key]): + raise OdooAuthError("Missing Odoo credentials") + + try: + common = self._get_common() + uid = common.authenticate(self.db, self.user, self.api_key, {}) + if not uid: + raise OdooAuthError("Invalid credentials") + self._uid = uid + return uid + except OdooAuthError: + raise + except Exception as e: + raise OdooConnectionError(f"Authentication failed: {e}") + + def execute( + self, + model: str, + method: str, + *args, + **kwargs, + ) -> Any: + """Execute Odoo method""" + uid = self.authenticate() + models = self._get_models() + + try: + return models.execute_kw( + self.db, + uid, + self.api_key, + model, + method, + list(args), + kwargs if kwargs else {}, + ) + except xmlrpc.client.Fault as e: + if "not found" in str(e).lower(): + raise OdooNotFoundError(str(e)) + if "validation" in str(e).lower(): + raise OdooValidationError(str(e)) + raise OdooError(str(e)) + + def search( + self, + model: str, + domain: list, + limit: int = None, + offset: int = 0, + order: str = None, + ) -> list: + """Search records""" + kwargs = {"offset": offset} + if limit: + kwargs["limit"] = limit + if order: + kwargs["order"] = order + return self.execute(model, "search", domain, **kwargs) + + def read( + self, + model: str, + ids: list, + fields: list = None, + ) -> list: + """Read records by IDs""" + kwargs = {} + if fields: + kwargs["fields"] = fields + return self.execute(model, "read", ids, **kwargs) + + def search_read( + self, + model: str, + domain: list, + fields: list = None, + limit: int = None, + offset: int = 0, + order: str = None, + ) -> list: + """Search and read in one call""" + kwargs = {"offset": offset} + if fields: + kwargs["fields"] = fields + if limit: + kwargs["limit"] = limit + if order: + kwargs["order"] = order + return self.execute(model, "search_read", domain, **kwargs) + + def create(self, model: str, values: dict) -> int: + """Create a record""" + return self.execute(model, "create", [values]) + + def write(self, model: str, ids: list, values: dict) -> bool: + """Update records""" + return self.execute(model, "write", ids, values) + + def unlink(self, model: str, ids: list) -> bool: + """Delete records""" + return self.execute(model, "unlink", ids) + + +@lru_cache +def get_odoo_client() -> OdooClient: + return OdooClient() diff --git a/services/integrations/app/odoo/exceptions.py b/services/integrations/app/odoo/exceptions.py new file mode 100644 index 0000000..55cd6a9 --- /dev/null +++ b/services/integrations/app/odoo/exceptions.py @@ -0,0 +1,23 @@ +class OdooError(Exception): + """Base Odoo exception""" + pass + + +class OdooConnectionError(OdooError): + """Failed to connect to Odoo""" + pass + + +class OdooAuthError(OdooError): + """Authentication failed""" + pass + + +class OdooNotFoundError(OdooError): + """Record not found""" + pass + + +class OdooValidationError(OdooError): + """Validation error from Odoo""" + pass