feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,10 @@ from app.services.psa.types import (
|
|||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
PSABoard,
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
from .client import ConnectWiseClient
|
from .client import ConnectWiseClient
|
||||||
|
|
||||||
@@ -55,20 +59,19 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
)
|
)
|
||||||
return self._map_ticket(data)
|
return self._map_ticket(data)
|
||||||
|
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
"""Search CW tickets by summary. Supports board_id, status_id, member_id,
|
"""Search CW tickets by summary. Supports board_id, status_id, member_identifier,
|
||||||
unassigned, board_ids, page, and page_size filters."""
|
unassigned, board_ids, page, and page_size filters. Returns paginated result."""
|
||||||
page_size = filters.get("page_size", 10)
|
page_size = filters.get("page_size", 10)
|
||||||
page = filters.get("page", 1)
|
page = filters.get("page", 1)
|
||||||
|
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"fields": "id,summary,company,board,status,priority,closedFlag",
|
"fields": "id,summary,company,board,status,priority,closedFlag",
|
||||||
"orderBy": "id desc",
|
"orderBy": "priority/sort asc,dateEntered desc",
|
||||||
"pageSize": page_size,
|
"pageSize": page_size,
|
||||||
"page": page,
|
"page": page,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build CW condition query
|
|
||||||
conditions: list[str] = []
|
conditions: list[str] = []
|
||||||
if query:
|
if query:
|
||||||
conditions.append(f"summary contains '{query}'")
|
conditions.append(f"summary contains '{query}'")
|
||||||
@@ -87,15 +90,24 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
board_list = ", ".join(str(bid) for bid in board_ids)
|
board_list = ", ".join(str(bid) for bid in board_ids)
|
||||||
conditions.append(f"board/id in ({board_list})")
|
conditions.append(f"board/id in ({board_list})")
|
||||||
|
|
||||||
if conditions:
|
condition_str = " and ".join(conditions) if conditions else ""
|
||||||
params["conditions"] = " and ".join(conditions)
|
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 [
|
# Fire page fetch + count in parallel
|
||||||
self._map_ticket(t)
|
data, count_data = await asyncio.gather(
|
||||||
for t in (data if isinstance(data, list) else [])
|
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(
|
async def get_ticket_configurations(
|
||||||
self, ticket_id: str
|
self, ticket_id: str
|
||||||
@@ -591,16 +603,112 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _map_ticket(data: dict) -> PSATicket:
|
def _map_ticket(data: dict) -> PSATicket:
|
||||||
"""Map a CW ticket JSON dict to a 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(
|
return PSATicket(
|
||||||
id=str(data["id"]),
|
id=str(data.get("id", "")),
|
||||||
summary=data.get("summary", ""),
|
summary=data.get("summary", ""),
|
||||||
company_name=data.get("company", {}).get("name"),
|
company_name=company.get("name"),
|
||||||
company_id=str(data["company"]["id"]) if data.get("company") else None,
|
company_id=str(company.get("id")) if company.get("id") else None,
|
||||||
board_name=data.get("board", {}).get("name"),
|
board_name=board.get("name"),
|
||||||
board_id=data.get("board", {}).get("id"),
|
board_id=board.get("id"),
|
||||||
status_name=data.get("status", {}).get("name"),
|
status_name=status.get("name"),
|
||||||
status_id=data.get("status", {}).get("id"),
|
status_id=status.get("id"),
|
||||||
priority_name=data.get("priority", {}).get("name"),
|
priority_name=priority.get("name"),
|
||||||
priority_id=data.get("priority", {}).get("id"),
|
priority_id=priority.get("id"),
|
||||||
closed=data.get("closedFlag", False),
|
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 [])
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user