|
|
|
|
@@ -266,13 +266,30 @@ class ConnectWiseProvider(PSAProvider):
|
|
|
|
|
async def update_ticket_status(
|
|
|
|
|
self, ticket_id: str, status_id: int
|
|
|
|
|
) -> PSATicket:
|
|
|
|
|
"""Update a CW ticket's status using JSON Patch format."""
|
|
|
|
|
"""Update a CW ticket's status using JSON Patch format.
|
|
|
|
|
|
|
|
|
|
Verifies CW actually applied the change — CW silently returns 200 when
|
|
|
|
|
a status id is invalid for the ticket's board. We check the response
|
|
|
|
|
body's status.id matches what we sent, and raise PSAError if not.
|
|
|
|
|
"""
|
|
|
|
|
patch_body = [
|
|
|
|
|
{"op": "replace", "path": "status", "value": {"id": status_id}}
|
|
|
|
|
]
|
|
|
|
|
data = await self.client.patch(
|
|
|
|
|
f"/service/tickets/{ticket_id}", json_body=patch_body
|
|
|
|
|
)
|
|
|
|
|
applied = (data.get("status") or {}) if isinstance(data, dict) else {}
|
|
|
|
|
applied_id = applied.get("id")
|
|
|
|
|
if applied_id != status_id:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"CW status PATCH for ticket %s returned status id=%s instead of %s",
|
|
|
|
|
ticket_id, applied_id, status_id,
|
|
|
|
|
)
|
|
|
|
|
raise PSAError(
|
|
|
|
|
f"ConnectWise did not apply status {status_id} "
|
|
|
|
|
f"(still {applied.get('name') or applied_id}). "
|
|
|
|
|
"The status may not be valid for this ticket's board."
|
|
|
|
|
)
|
|
|
|
|
return self._map_ticket(data)
|
|
|
|
|
|
|
|
|
|
async def list_members(self) -> list[PSAMember]:
|
|
|
|
|
@@ -631,58 +648,107 @@ class ConnectWiseProvider(PSAProvider):
|
|
|
|
|
|
|
|
|
|
# ── Resource management ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async def _get_ticket_resource_identifiers(self, ticket_id: int) -> list[str]:
|
|
|
|
|
"""Fetch the ticket's `resources` field (comma-separated identifiers)."""
|
|
|
|
|
async def _get_ticket_assignment(self, ticket_id: int) -> tuple[dict | None, str | None]:
|
|
|
|
|
"""Fetch the ticket's current owner (MemberReference) and resources string.
|
|
|
|
|
|
|
|
|
|
Returns (owner_dict, resources_string). owner_dict has keys like
|
|
|
|
|
{id, identifier, name} when set; None when unassigned.
|
|
|
|
|
"""
|
|
|
|
|
data = await self.client.get(
|
|
|
|
|
f"/service/tickets/{ticket_id}",
|
|
|
|
|
params={"fields": "id,resources"},
|
|
|
|
|
params={"fields": "id,owner,resources"},
|
|
|
|
|
)
|
|
|
|
|
raw = data.get("resources") if isinstance(data, dict) else None
|
|
|
|
|
if not raw:
|
|
|
|
|
return []
|
|
|
|
|
return [p.strip() for p in str(raw).split(",") if p.strip()]
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
return None, None
|
|
|
|
|
owner_raw = data.get("owner")
|
|
|
|
|
owner = owner_raw if isinstance(owner_raw, dict) and owner_raw.get("id") else None
|
|
|
|
|
resources = data.get("resources")
|
|
|
|
|
return owner, (str(resources) if resources else None)
|
|
|
|
|
|
|
|
|
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
|
|
|
|
"""List members assigned to a CW ticket via the `resources` string field."""
|
|
|
|
|
identifiers = await self._get_ticket_resource_identifiers(ticket_id)
|
|
|
|
|
if not identifiers:
|
|
|
|
|
return []
|
|
|
|
|
"""List members assigned to a CW ticket.
|
|
|
|
|
|
|
|
|
|
CW exposes assignment in two places: the `owner` MemberReference (primary
|
|
|
|
|
assignee) and a derived `resources` string of identifiers. We merge both
|
|
|
|
|
so the UI shows everyone CW considers assigned.
|
|
|
|
|
"""
|
|
|
|
|
owner, resources_str = await self._get_ticket_assignment(ticket_id)
|
|
|
|
|
members = await self.list_members()
|
|
|
|
|
by_identifier = {m.identifier: m for m in members if m.identifier}
|
|
|
|
|
by_id = {str(m.id): m for m in members}
|
|
|
|
|
|
|
|
|
|
seen_ids: set[str] = set()
|
|
|
|
|
results: list[PSAResource] = []
|
|
|
|
|
for ident in identifiers:
|
|
|
|
|
m = by_identifier.get(ident)
|
|
|
|
|
|
|
|
|
|
if owner is not None:
|
|
|
|
|
owner_id = str(owner.get("id"))
|
|
|
|
|
m = by_id.get(owner_id)
|
|
|
|
|
if m:
|
|
|
|
|
results.append(PSAResource(
|
|
|
|
|
member_id=int(m.id),
|
|
|
|
|
member_name=m.name,
|
|
|
|
|
member_identifier=m.identifier,
|
|
|
|
|
))
|
|
|
|
|
seen_ids.add(owner_id)
|
|
|
|
|
else:
|
|
|
|
|
# Unknown identifier — surface it so the UI doesn't silently drop it
|
|
|
|
|
results.append(PSAResource(
|
|
|
|
|
member_id=0,
|
|
|
|
|
member_name=ident,
|
|
|
|
|
member_identifier=ident,
|
|
|
|
|
member_id=int(owner.get("id") or 0),
|
|
|
|
|
member_name=str(owner.get("name") or ""),
|
|
|
|
|
member_identifier=str(owner.get("identifier") or ""),
|
|
|
|
|
))
|
|
|
|
|
seen_ids.add(owner_id)
|
|
|
|
|
|
|
|
|
|
if resources_str:
|
|
|
|
|
for ident in (p.strip() for p in resources_str.split(",") if p.strip()):
|
|
|
|
|
m = by_identifier.get(ident)
|
|
|
|
|
if m and str(m.id) not in seen_ids:
|
|
|
|
|
results.append(PSAResource(
|
|
|
|
|
member_id=int(m.id),
|
|
|
|
|
member_name=m.name,
|
|
|
|
|
member_identifier=m.identifier,
|
|
|
|
|
))
|
|
|
|
|
seen_ids.add(str(m.id))
|
|
|
|
|
elif not m:
|
|
|
|
|
results.append(PSAResource(
|
|
|
|
|
member_id=0,
|
|
|
|
|
member_name=ident,
|
|
|
|
|
member_identifier=ident,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
|
|
|
|
"""Assign a member to a CW ticket by updating the ticket's `resources` field."""
|
|
|
|
|
"""Assign a member to a CW ticket.
|
|
|
|
|
|
|
|
|
|
CW's `resources` string is derived server-side and PATCHing it is unreliable.
|
|
|
|
|
The canonical writable field is `owner`. If the ticket is unassigned we set
|
|
|
|
|
the owner; if it already has a different owner, we append to `resources`
|
|
|
|
|
(best-effort — CW may not surface it in every view).
|
|
|
|
|
"""
|
|
|
|
|
members = await self.list_members()
|
|
|
|
|
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
|
|
|
|
if target is None or not target.identifier:
|
|
|
|
|
raise PSAError(f"Member {member_id} not found or has no identifier")
|
|
|
|
|
if target is None:
|
|
|
|
|
raise PSAError(f"Member {member_id} not found")
|
|
|
|
|
|
|
|
|
|
current = await self._get_ticket_resource_identifiers(ticket_id)
|
|
|
|
|
if target.identifier not in current:
|
|
|
|
|
current.append(target.identifier)
|
|
|
|
|
new_value = ",".join(current)
|
|
|
|
|
current_owner, resources_str = await self._get_ticket_assignment(ticket_id)
|
|
|
|
|
|
|
|
|
|
if current_owner is None:
|
|
|
|
|
# Primary assign — set owner
|
|
|
|
|
await self.client.patch(
|
|
|
|
|
f"/service/tickets/{ticket_id}",
|
|
|
|
|
json_body=[{"op": "replace", "path": "owner", "value": {"id": int(target.id)}}],
|
|
|
|
|
)
|
|
|
|
|
elif str(current_owner.get("id")) != str(target.id):
|
|
|
|
|
# Ticket already owned by someone else — add to resources string as co-assignee.
|
|
|
|
|
identifiers = [p.strip() for p in (resources_str or "").split(",") if p.strip()]
|
|
|
|
|
if target.identifier and target.identifier not in identifiers:
|
|
|
|
|
identifiers.append(target.identifier)
|
|
|
|
|
await self.client.patch(
|
|
|
|
|
f"/service/tickets/{ticket_id}",
|
|
|
|
|
json_body=[{"op": "replace", "path": "resources", "value": ",".join(identifiers)}],
|
|
|
|
|
)
|
|
|
|
|
# else: already the owner — idempotent no-op
|
|
|
|
|
|
|
|
|
|
await self.client.patch(
|
|
|
|
|
f"/service/tickets/{ticket_id}",
|
|
|
|
|
json_body=[{"op": "replace", "path": "resources", "value": new_value}],
|
|
|
|
|
)
|
|
|
|
|
return PSAResource(
|
|
|
|
|
member_id=int(target.id),
|
|
|
|
|
member_name=target.name,
|
|
|
|
|
@@ -690,22 +756,41 @@ class ConnectWiseProvider(PSAProvider):
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
|
|
|
|
"""Remove a member from a CW ticket by updating the `resources` field (idempotent)."""
|
|
|
|
|
"""Remove a member from a CW ticket (idempotent).
|
|
|
|
|
|
|
|
|
|
If the target is the current owner, clear the owner. Otherwise remove them
|
|
|
|
|
from the `resources` string.
|
|
|
|
|
"""
|
|
|
|
|
members = await self.list_members()
|
|
|
|
|
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
|
|
|
|
if target is None or not target.identifier:
|
|
|
|
|
return # Unknown member — treat as idempotent
|
|
|
|
|
if target is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
current = await self._get_ticket_resource_identifiers(ticket_id)
|
|
|
|
|
if target.identifier not in current:
|
|
|
|
|
return # Already not assigned — idempotent
|
|
|
|
|
new_list = [i for i in current if i != target.identifier]
|
|
|
|
|
new_value = ",".join(new_list)
|
|
|
|
|
current_owner, resources_str = await self._get_ticket_assignment(ticket_id)
|
|
|
|
|
|
|
|
|
|
await self.client.patch(
|
|
|
|
|
f"/service/tickets/{ticket_id}",
|
|
|
|
|
json_body=[{"op": "replace", "path": "resources", "value": new_value}],
|
|
|
|
|
)
|
|
|
|
|
if current_owner is not None and str(current_owner.get("id")) == str(target.id):
|
|
|
|
|
# Unassign the owner. Try RFC 6902 "remove" first; fall back to
|
|
|
|
|
# "replace" with null if CW rejects it.
|
|
|
|
|
try:
|
|
|
|
|
await self.client.patch(
|
|
|
|
|
f"/service/tickets/{ticket_id}",
|
|
|
|
|
json_body=[{"op": "remove", "path": "owner"}],
|
|
|
|
|
)
|
|
|
|
|
except PSAError:
|
|
|
|
|
await self.client.patch(
|
|
|
|
|
f"/service/tickets/{ticket_id}",
|
|
|
|
|
json_body=[{"op": "replace", "path": "owner", "value": None}],
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if target.identifier and resources_str:
|
|
|
|
|
identifiers = [p.strip() for p in resources_str.split(",") if p.strip()]
|
|
|
|
|
if target.identifier in identifiers:
|
|
|
|
|
new_list = [i for i in identifiers if i != target.identifier]
|
|
|
|
|
await self.client.patch(
|
|
|
|
|
f"/service/tickets/{ticket_id}",
|
|
|
|
|
json_body=[{"op": "replace", "path": "resources", "value": ",".join(new_list)}],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Ticket creation ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|