"""ConnectWise implementation of PSAProvider.""" 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, 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, ) # ── Tickets ─────────────────────────────────────────────────────── async def get_ticket(self, ticket_id: str) -> PSATicket: """Fetch a single ticket by ID from ConnectWise.""" data = await self.client.get( f"/service/tickets/{ticket_id}", params={"fields": "id,summary,company,board,status,priority,closedFlag"}, ) return self._map_ticket(data) async def search_tickets(self, query: str, **filters) -> list[PSATicket]: """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, ) # ── Notes & status updates ─────────────────────────────────────── async def post_note( self, ticket_id: str, text: str, note_type: str, member_id: str | None = None, ) -> PSANote: """Post a note to a CW ticket. Maps ResolutionFlow note types to CW flag fields: - internal_analysis → internalAnalysisFlag (internal only) - resolution → resolutionFlag (internal, triggers notifications) - description → detailDescriptionFlag (external, triggers notifications) """ from app.services.psa.types import NoteType flags = { NoteType.INTERNAL_ANALYSIS: { "internalAnalysisFlag": True, "resolutionFlag": False, "detailDescriptionFlag": False, "internalFlag": True, "processNotifications": False, }, NoteType.RESOLUTION: { "internalAnalysisFlag": False, "resolutionFlag": True, "detailDescriptionFlag": False, "internalFlag": True, "processNotifications": True, }, NoteType.DESCRIPTION: { "internalAnalysisFlag": False, "resolutionFlag": False, "detailDescriptionFlag": True, "internalFlag": False, "processNotifications": True, }, } note_flags = flags.get(note_type, flags[NoteType.INTERNAL_ANALYSIS]) # NOTE: CW Developer Guide states \n is "Not Supported" in JSON bodies # and may be collapsed to a single space. CW does support markdown in ticket # notes (see PSA-Markdown.md). This needs sandbox testing — if newlines are # lost, consider using double-space line breaks or HTML
tags instead. body: dict = { "text": text, **note_flags, } if member_id: body["member"] = {"id": int(member_id)} data = await self.client.post( f"/service/tickets/{ticket_id}/notes", json_body=body ) return PSANote( id=str(data.get("id", "")), text=data.get("text", ""), note_type=note_type, created_at=data.get("dateCreated"), ) async def update_ticket_status( self, ticket_id: str, status_id: int ) -> PSATicket: """Update a CW ticket's status using JSON Patch format.""" patch_body = [ {"op": "replace", "path": "status", "value": {"id": status_id}} ] data = await self.client.patch( f"/service/tickets/{ticket_id}", json_body=patch_body ) return self._map_ticket(data) async def list_members(self) -> list[PSAMember]: """List CW system members (cached 15 minutes).""" cache_key = "members:all" cached = psa_cache.get(cache_key) if cached is not None: return cached data = await self.client.get_paginated( "/system/members", params={ "fields": "id,identifier,firstName,lastName,officeEmail", "conditions": "inactiveFlag = false", "pageSize": 1000, }, ) result = [ PSAMember( id=str(m["id"]), identifier=m.get("identifier", ""), name=f"{m.get('firstName', '')} {m.get('lastName', '')}".strip(), email=m.get("officeEmail"), ) for m in data ] psa_cache.set(cache_key, result, ttl_seconds=900) return result # ── 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), )