From 2086ecb5ea6b515346b680ad35a23358b95ef096 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 21:52:23 -0400 Subject: [PATCH] feat(psa): add ConnectWiseProvider with test_connection + provider registry ConnectWiseProvider implements PSAProvider with test_connection() that calls GET /system/info to verify credentials and connectivity. All other methods raise NotImplementedError with slice references for future work. Provider registry (get_provider_for_account) looks up the account's PsaConnection, decrypts stored credentials, and instantiates the correct provider. Currently supports connectwise only. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/psa/connectwise/provider.py | 76 +++++++++++++++++++ backend/app/services/psa/registry.py | 50 ++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 backend/app/services/psa/connectwise/provider.py create mode 100644 backend/app/services/psa/registry.py diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py new file mode 100644 index 00000000..6a9f211d --- /dev/null +++ b/backend/app/services/psa/connectwise/provider.py @@ -0,0 +1,76 @@ +"""ConnectWise implementation of PSAProvider.""" +from __future__ import annotations + +from app.services.psa.base import PSAProvider +from app.services.psa.types import ( + ConnectionTestResult, + PSATicket, + PSANote, + PSAStatus, + PSACompany, + PSAMember, + PSAConfiguration, +) +from .client import ConnectWiseClient + + +class ConnectWiseProvider(PSAProvider): + """ConnectWise PSA provider implementation.""" + + def __init__(self, client: ConnectWiseClient): + self.client = client + + async def test_connection(self) -> ConnectionTestResult: + """Test the CW connection by fetching system info.""" + try: + info = await self.client.get("/system/info") + return ConnectionTestResult( + success=True, + message="Connected successfully.", + server_version=info.get("version", None), + ) + except Exception as e: + return ConnectionTestResult( + success=False, + message=str(e), + server_version=None, + ) + + # ── Stubs for Phase A slices 2-5 ───────────────────────────────── + + async def get_ticket(self, ticket_id: str) -> PSATicket: + raise NotImplementedError("Implemented in Slice 2") + + async def search_tickets(self, query: str, **filters) -> list[PSATicket]: + raise NotImplementedError("Implemented in Slice 3") + + async def post_note( + self, + ticket_id: str, + text: str, + note_type: str, + member_id: str | None = None, + ) -> PSANote: + raise NotImplementedError("Implemented in Slice 4") + + async def update_ticket_status( + self, ticket_id: str, status_id: int + ) -> PSATicket: + raise NotImplementedError("Implemented in Slice 4") + + async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]: + raise NotImplementedError("Implemented in Slice 3") + + async def list_companies(self, **filters) -> list[PSACompany]: + raise NotImplementedError("Implemented in Slice 3") + + async def get_company(self, company_id: str) -> PSACompany: + raise NotImplementedError("Implemented in Slice 3") + + async def list_members(self) -> list[PSAMember]: + raise NotImplementedError("Implemented in Slice 5") + + async def get_ticket_configurations( + self, ticket_id: str + ) -> list[PSAConfiguration]: + raise NotImplementedError("Implemented in Slice 3") diff --git a/backend/app/services/psa/registry.py b/backend/app/services/psa/registry.py new file mode 100644 index 00000000..7f4428ac --- /dev/null +++ b/backend/app/services/psa/registry.py @@ -0,0 +1,50 @@ +"""Factory for instantiating PSA providers from stored connection data.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.psa_connection import PsaConnection +from app.services.psa.base import PSAProvider +from app.services.psa.encryption import decrypt_credentials +from app.services.psa.exceptions import PSAConnectionError + + +async def get_provider_for_account( + account_id: UUID, db: AsyncSession +) -> PSAProvider: + """Look up account's PSA connection, decrypt credentials, instantiate provider.""" + result = await db.execute( + select(PsaConnection).where( + PsaConnection.account_id == account_id, + PsaConnection.is_active.is_(True), + ) + ) + connection = result.scalar_one_or_none() + + if not connection: + raise PSAConnectionError( + "No active PSA connection configured for this account.", + provider="unknown", + ) + + if connection.provider == "connectwise": + from app.services.psa.connectwise.client import ConnectWiseClient + from app.services.psa.connectwise.provider import ConnectWiseProvider + + creds = decrypt_credentials(connection.credentials_encrypted) + client = ConnectWiseClient( + site_url=connection.site_url, + company_id=connection.company_id, + public_key=creds["public_key"], + private_key=creds["private_key"], + client_id=creds["client_id"], + ) + return ConnectWiseProvider(client) + + raise PSAConnectionError( + f"Unsupported PSA provider: {connection.provider}", + provider=connection.provider, + )