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:
14
src/tryton_mcp/services/__init__.py
Normal file
14
src/tryton_mcp/services/__init__.py
Normal 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",
|
||||
]
|
||||
28
src/tryton_mcp/services/base.py
Normal file
28
src/tryton_mcp/services/base.py
Normal 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
|
||||
43
src/tryton_mcp/services/party.py
Normal file
43
src/tryton_mcp/services/party.py
Normal 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, {}],
|
||||
)
|
||||
39
src/tryton_mcp/services/product.py
Normal file
39
src/tryton_mcp/services/product.py
Normal 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, {}],
|
||||
)
|
||||
37
src/tryton_mcp/services/provider.py
Normal file
37
src/tryton_mcp/services/provider.py
Normal 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
|
||||
53
src/tryton_mcp/services/schedule.py
Normal file
53
src/tryton_mcp/services/schedule.py
Normal 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, {}],
|
||||
)
|
||||
32
src/tryton_mcp/services/service_center.py
Normal file
32
src/tryton_mcp/services/service_center.py
Normal 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, {}],
|
||||
)
|
||||
Reference in New Issue
Block a user