From e714088a2b08dad21f4bfc82693dad5e42d3f9ed Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 02:49:20 +0000 Subject: [PATCH] feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider Co-Authored-By: Claude Sonnet 4.6 --- .../app/services/psa/connectwise/provider.py | 150 +++++++++++++++--- 1 file changed, 129 insertions(+), 21 deletions(-) diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index af154cdd..465f66ab 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -17,6 +17,10 @@ from app.services.psa.types import ( PSAConfiguration, PSATimeEntry, PSABoard, + PaginatedTicketResult, + PSAResource, + PSACreatedTicket, + TicketCreatePayload, ) from .client import ConnectWiseClient @@ -55,20 +59,19 @@ class ConnectWiseProvider(PSAProvider): ) return self._map_ticket(data) - async def search_tickets(self, query: str, **filters) -> list[PSATicket]: - """Search CW tickets by summary. Supports board_id, status_id, member_id, - unassigned, board_ids, page, and page_size filters.""" + async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: + """Search CW tickets by summary. Supports board_id, status_id, member_identifier, + unassigned, board_ids, page, and page_size filters. Returns paginated result.""" page_size = filters.get("page_size", 10) page = filters.get("page", 1) params: dict = { "fields": "id,summary,company,board,status,priority,closedFlag", - "orderBy": "id desc", + "orderBy": "priority/sort asc,dateEntered desc", "pageSize": page_size, "page": page, } - # Build CW condition query conditions: list[str] = [] if query: conditions.append(f"summary contains '{query}'") @@ -87,15 +90,24 @@ class ConnectWiseProvider(PSAProvider): board_list = ", ".join(str(bid) for bid in board_ids) conditions.append(f"board/id in ({board_list})") - if conditions: - params["conditions"] = " and ".join(conditions) + condition_str = " and ".join(conditions) if conditions else "" + if condition_str: + params["conditions"] = condition_str - data = await self.client.get("/service/tickets", params=params) + count_params: dict = {} + if condition_str: + count_params["conditions"] = condition_str - return [ - self._map_ticket(t) - for t in (data if isinstance(data, list) else []) - ] + # Fire page fetch + count in parallel + data, count_data = await asyncio.gather( + self.client.get("/service/tickets", params=params), + self.client.get("/service/tickets/count", params=count_params), + ) + + items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])] + total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items) + + return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size) async def get_ticket_configurations( self, ticket_id: str @@ -591,16 +603,112 @@ class ConnectWiseProvider(PSAProvider): @staticmethod def _map_ticket(data: dict) -> PSATicket: """Map a CW ticket JSON dict to a PSATicket.""" + company = data.get("company") or {} + board = data.get("board") or {} + status = data.get("status") or {} + priority = data.get("priority") or {} return PSATicket( - id=str(data["id"]), + id=str(data.get("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"), + company_name=company.get("name"), + company_id=str(company.get("id")) if company.get("id") else None, + board_name=board.get("name"), + board_id=board.get("id"), + status_name=status.get("name"), + status_id=status.get("id"), + priority_name=priority.get("name"), + priority_id=priority.get("id"), closed=data.get("closedFlag", False), ) + + # ── Resource management ─────────────────────────────────────────── + + async def list_resources(self, ticket_id: int) -> list[PSAResource]: + """List members assigned to a CW ticket.""" + data = await self.client.get(f"/service/tickets/{ticket_id}/members") + results = [] + for m in (data if isinstance(data, list) else []): + member = m.get("member") or {} + results.append(PSAResource( + member_id=member.get("id", 0), + member_name=member.get("name", ""), + member_identifier=member.get("identifier", ""), + )) + return results + + async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: + """Assign a member to a CW ticket.""" + data = await self.client.post( + f"/service/tickets/{ticket_id}/members", + json_body={"member": {"id": member_id}}, + ) + member = (data.get("member") or {}) if isinstance(data, dict) else {} + return PSAResource( + member_id=member.get("id", member_id), + member_name=member.get("name", ""), + member_identifier=member.get("identifier", ""), + ) + + async def remove_resource(self, ticket_id: int, member_id: int) -> None: + """Remove a member from a CW ticket (idempotent).""" + # CW DELETE requires the member record id (junction record), not the member's id + members_data = await self.client.get(f"/service/tickets/{ticket_id}/members") + record_id = None + for m in (members_data if isinstance(members_data, list) else []): + if (m.get("member") or {}).get("id") == member_id: + record_id = m.get("id") + break + if record_id is None: + return # Already not assigned — idempotent + await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}") + + # ── Ticket creation ─────────────────────────────────────────────── + + async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket: + """Create a new CW service ticket.""" + body: dict = { + "summary": payload.summary, + "board": {"id": payload.board_id}, + "company": {"id": payload.company_id}, + "status": {"id": payload.status_id}, + "priority": {"id": payload.priority_id}, + } + if payload.description: + body["initialDescription"] = payload.description + if payload.assigned_member_id: + body["owner"] = {"id": payload.assigned_member_id} + + data = await self.client.post("/service/tickets", json_body=body) + + ticket_id = data.get("id") if isinstance(data, dict) else None + resources: list[PSAResource] = [] + if ticket_id and payload.assigned_member_id: + try: + resources = await self.list_resources(ticket_id) + except Exception: + pass + + company = (data.get("company") or {}) if isinstance(data, dict) else {} + board = (data.get("board") or {}) if isinstance(data, dict) else {} + status = (data.get("status") or {}) if isinstance(data, dict) else {} + priority = (data.get("priority") or {}) if isinstance(data, dict) else {} + + return PSACreatedTicket( + id=ticket_id or 0, + summary=data.get("summary", payload.summary) if isinstance(data, dict) else payload.summary, + board_name=board.get("name", ""), + status_name=status.get("name", ""), + priority_name=priority.get("name", ""), + company_name=company.get("name", ""), + resources=resources, + ) + + # ── Priorities ──────────────────────────────────────────────────── + + async def list_priorities(self) -> list[dict]: + """List CW service priorities.""" + data = await self.client.get("/service/priorities", params={"pageSize": 50}) + return [ + {"id": p.get("id"), "name": p.get("name")} + for p in (data if isinstance(data, list) else []) + ]