From 5a35c933e0d29bc65b6b1941a0e03bb328417e2a Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:06:57 -0400 Subject: [PATCH] feat(psa): implement search_tickets, get_ticket_statuses, list_companies in CW provider Implement all remaining NotImplementedError stubs in ConnectWiseProvider: - search_tickets: query by summary with board_id, status_id, include_closed filters - get_ticket_statuses: fetch statuses for a service board - list_companies: list companies with optional status filter - get_company: fetch a single company by ID - get_ticket_configurations: fetch configs attached to a ticket - Extract shared _map_ticket helper to reduce duplication Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/services/psa/connectwise/provider.py | 157 ++++++++++++++---- 1 file changed, 129 insertions(+), 28 deletions(-) diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index b7527395..615d2c89 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -2,6 +2,7 @@ from __future__ import annotations from app.services.psa.base import PSAProvider +from app.services.psa.cache import psa_cache from app.services.psa.types import ( ConnectionTestResult, PSATicket, @@ -36,7 +37,7 @@ class ConnectWiseProvider(PSAProvider): server_version=None, ) - # ── Stubs for Phase A slices 2-5 ───────────────────────────────── + # ── Tickets ─────────────────────────────────────────────────────── async def get_ticket(self, ticket_id: str) -> PSATicket: """Fetch a single ticket by ID from ConnectWise.""" @@ -44,22 +45,117 @@ class ConnectWiseProvider(PSAProvider): f"/service/tickets/{ticket_id}", params={"fields": "id,summary,company,board,status,priority,closedFlag"}, ) - return PSATicket( - id=str(data["id"]), - summary=data.get("summary", ""), - company_name=data.get("company", {}).get("name"), - company_id=str(data["company"]["id"]) if data.get("company") else None, - board_name=data.get("board", {}).get("name"), - board_id=data.get("board", {}).get("id"), - status_name=data.get("status", {}).get("name"), - status_id=data.get("status", {}).get("id"), - priority_name=data.get("priority", {}).get("name"), - priority_id=data.get("priority", {}).get("id"), - closed=data.get("closedFlag", False), - ) + return self._map_ticket(data) async def search_tickets(self, query: str, **filters) -> list[PSATicket]: - raise NotImplementedError("Implemented in Slice 3") + """Search CW tickets by summary. Supports board_id and status_id filters.""" + params: dict = { + "fields": "id,summary,company,board,status,priority,closedFlag", + "orderBy": "id desc", + "pageSize": 25, + } + + # Build CW condition query + conditions: list[str] = [] + if query: + conditions.append(f"summary contains '{query}'") + if filters.get("board_id"): + conditions.append(f"board/id = {filters['board_id']}") + if filters.get("status_id"): + conditions.append(f"status/id = {filters['status_id']}") + if not filters.get("include_closed", False): + conditions.append("closedFlag = false") + + if conditions: + params["conditions"] = " and ".join(conditions) + + data = await self.client.get("/service/tickets", params=params) + + return [ + self._map_ticket(t) + for t in (data if isinstance(data, list) else []) + ] + + async def get_ticket_configurations( + self, ticket_id: str + ) -> list[PSAConfiguration]: + """Get configurations (assets) attached to a ticket.""" + data = await self.client.get( + f"/service/tickets/{ticket_id}/configurations", + params={"fields": "id,deviceIdentifier,type,company"}, + ) + return [ + PSAConfiguration( + id=str(c["id"]), + name=c.get("deviceIdentifier", ""), + type=c.get("type", {}).get("name") if c.get("type") else None, + company_name=c.get("company", {}).get("name") if c.get("company") else None, + ) + for c in (data if isinstance(data, list) else []) + ] + + # ── Board statuses (cached) ─────────────────────────────────────── + + async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]: + """Get available statuses for a CW service board (cached 1 hour).""" + cache_key = f"board_statuses:{board_id}" + cached = psa_cache.get(cache_key) + if cached is not None: + return cached + + data = await self.client.get( + f"/service/boards/{board_id}/statuses", + params={"fields": "id,name,closedStatus", "pageSize": 100}, + ) + result = [ + PSAStatus( + id=s["id"], + name=s["name"], + is_closed=s.get("closedStatus", False), + ) + for s in (data if isinstance(data, list) else []) + ] + psa_cache.set(cache_key, result, ttl_seconds=3600) + return result + + # ── Companies ───────────────────────────────────────────────────── + + async def list_companies(self, **filters) -> list[PSACompany]: + """List companies from CW, optionally filtered by status.""" + params: dict = { + "fields": "id,name,status", + "pageSize": 100, + "orderBy": "name asc", + } + conditions: list[str] = [] + if filters.get("status"): + conditions.append(f"status/name = '{filters['status']}'") + if conditions: + params["conditions"] = " and ".join(conditions) + + data = await self.client.get("/company/companies", params=params) + return [ + PSACompany( + id=str(c["id"]), + name=c.get("name", ""), + status=c.get("status", {}).get("name") if c.get("status") else None, + ) + for c in (data if isinstance(data, list) else []) + ] + + async def get_company(self, company_id: str) -> PSACompany: + """Fetch a single company by ID.""" + data = await self.client.get( + f"/company/companies/{company_id}", + params={"fields": "id,name,status"}, + ) + return PSACompany( + id=str(data["id"]), + name=data.get("name", ""), + status=data.get("status", {}).get("name") if data.get("status") else None, + ) + + # ── Stubs for later slices ──────────────────────────────────────── async def post_note( self, @@ -75,19 +171,24 @@ class ConnectWiseProvider(PSAProvider): ) -> 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") + # ── Private helpers ─────────────────────────────────────────────── + + @staticmethod + def _map_ticket(data: dict) -> PSATicket: + """Map a CW ticket JSON dict to a PSATicket.""" + return PSATicket( + id=str(data["id"]), + summary=data.get("summary", ""), + company_name=data.get("company", {}).get("name"), + company_id=str(data["company"]["id"]) if data.get("company") else None, + board_name=data.get("board", {}).get("name"), + board_id=data.get("board", {}).get("id"), + status_name=data.get("status", {}).get("name"), + status_id=data.get("status", {}).get("id"), + priority_name=data.get("priority", {}).get("name"), + priority_id=data.get("priority", {}).get("id"), + closed=data.get("closedFlag", False), + )