refactor: add service layer architecture with Pydantic validation

- Add services layer (PartyService, ScheduleService, ProductService, ServiceCenterService)
- Add ServiceProvider for dependency injection
- Add Pydantic input validation on all MCP tools
- Add thread-safety to TrytonSettings with double-checked locking
- Update tests to mock ServiceProvider instead of direct client calls
- Add pydantic dependency
This commit is contained in:
2026-03-14 22:12:33 -05:00
parent 6154b83455
commit 861f5030f4
13 changed files with 545 additions and 199 deletions

View File

@@ -0,0 +1,14 @@
from tryton_mcp.services.base import BaseService, TrytonResponse
from tryton_mcp.services.party import PartyService
from tryton_mcp.services.schedule import ScheduleService
from tryton_mcp.services.product import ProductService
from tryton_mcp.services.service_center import ServiceCenterService
__all__ = [
"BaseService",
"TrytonResponse",
"PartyService",
"ScheduleService",
"ProductService",
"ServiceCenterService",
]

View File

@@ -0,0 +1,28 @@
from typing import Any, Dict, List, Optional
from dataclasses import dataclass
@dataclass
class TrytonResponse:
success: bool
data: Any = None
error: Optional[str] = None
@property
def is_error(self) -> bool:
return not self.success
class BaseService:
def __init__(self, client: Any):
self._client = client
def call(self, method: str, args: List[Any]) -> TrytonResponse:
try:
response = self._client.call(method, args)
return TrytonResponse(success=True, data=response)
except Exception as e:
return TrytonResponse(success=False, error=str(e))
def _get_client(self) -> Any:
return self._client

View File

@@ -0,0 +1,43 @@
from typing import Any, Dict, List, Optional
from tryton_mcp.services.base import BaseService, TrytonResponse
class PartyService(BaseService):
def find_by_identifier(self, identifier: str) -> TrytonResponse:
return self.call(
"model.party.identifier.search_read",
[
[["code", "=", identifier]],
0,
None,
None,
["party", "party.name"],
{"company": 1},
],
)
def create(
self, name: str, identifiers: List[tuple[str, List[Dict[str, Any]]]]
) -> TrytonResponse:
return self.call(
"model.party.party.create",
[{"name": name, "identifiers": identifiers}, {}],
)
def search_parties(
self,
domain: Optional[List[Any]] = None,
offset: int = 0,
limit: Optional[int] = None,
fields: Optional[List[str]] = None,
) -> TrytonResponse:
if domain is None:
domain = []
if fields is None:
fields = ["name", "id"]
return self.call(
"model.party.party.search_read",
[domain, offset, limit, None, fields, {}],
)

View File

@@ -0,0 +1,39 @@
from typing import Any, List, Optional
from tryton_mcp.services.base import BaseService, TrytonResponse
class ProductService(BaseService):
def find_salable(
self,
offset: int = 0,
limit: Optional[int] = None,
) -> TrytonResponse:
return self.call(
"model.product.product.search_read",
[
[["active", "=", True], ["salable", "=", True]],
offset,
limit,
None,
["name", "list_price", "description"],
{"company": 1},
],
)
def search(
self,
domain: Optional[List[Any]] = None,
offset: int = 0,
limit: Optional[int] = None,
fields: Optional[List[str]] = None,
) -> TrytonResponse:
if domain is None:
domain = []
if fields is None:
fields = ["id", "name", "list_price"]
return self.call(
"model.product.product.search_read",
[domain, offset, limit, None, fields, {}],
)

View File

@@ -0,0 +1,37 @@
from tryton_mcp.services.party import PartyService
from tryton_mcp.services.schedule import ScheduleService
from tryton_mcp.services.product import ProductService
from tryton_mcp.services.service_center import ServiceCenterService
class ServiceProvider:
def __init__(self, client):
self._client = client
self._party_service: PartyService | None = None
self._schedule_service: ScheduleService | None = None
self._product_service: ProductService | None = None
self._service_center_service: ServiceCenterService | None = None
@property
def party(self) -> PartyService:
if self._party_service is None:
self._party_service = PartyService(self._client)
return self._party_service
@property
def schedule(self) -> ScheduleService:
if self._schedule_service is None:
self._schedule_service = ScheduleService(self._client)
return self._schedule_service
@property
def product(self) -> ProductService:
if self._product_service is None:
self._product_service = ProductService(self._client)
return self._product_service
@property
def service_center(self) -> ServiceCenterService:
if self._service_center_service is None:
self._service_center_service = ServiceCenterService(self._client)
return self._service_center_service

View File

@@ -0,0 +1,53 @@
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from tryton_mcp.services.base import BaseService, TrytonResponse
class ScheduleService(BaseService):
def create(
self,
professional: int,
description: str,
customer: int,
date: str,
service_center: int,
) -> TrytonResponse:
try:
dt = datetime.fromisoformat(date)
except ValueError as e:
return TrytonResponse(
success=False,
error=f"Invalid date format: '{date}'. Expected ISO format. Error: {e}",
)
utc_dt = dt.astimezone(timezone.utc)
schedule_data = [
{
"professional": professional,
"description": description,
"customer": customer,
"date": utc_dt,
"service_center": service_center,
}
]
return self.call("model.naliia.schedule.create", [schedule_data, {}])
def search(
self,
domain: Optional[List[Any]] = None,
offset: int = 0,
limit: Optional[int] = None,
fields: Optional[List[str]] = None,
) -> TrytonResponse:
if domain is None:
domain = []
if fields is None:
fields = ["id", "professional", "description", "customer", "date"]
return self.call(
"model.naliia.schedule.search_read",
[domain, offset, limit, None, fields, {}],
)

View File

@@ -0,0 +1,32 @@
from typing import Any, List, Optional
from tryton_mcp.services.base import BaseService, TrytonResponse
class ServiceCenterService(BaseService):
def find_all(
self,
offset: int = 0,
limit: Optional[int] = None,
) -> TrytonResponse:
return self.call(
"model.naliia.service_center.search_read",
[[[]], offset, limit, None, ["name", "address.street"], {}],
)
def search(
self,
domain: Optional[List[Any]] = None,
offset: int = 0,
limit: Optional[int] = None,
fields: Optional[List[str]] = None,
) -> TrytonResponse:
if domain is None:
domain = []
if fields is None:
fields = ["id", "name", "address.street"]
return self.call(
"model.naliia.service_center.search_read",
[domain, offset, limit, None, fields, {}],
)