fix(psa): use schedule entries for ticket co-assignees (CW canonical pattern)
The previous implementation PATCHed the `resources` string directly, which CW
silently ignores because `resources` is a server-derived read-only field (it's
populated from schedule entries of type/id=4, not freely writable).
Per CW docs (openapi line 70949): "Please use the
/schedule/entries?conditions=type/id=4 AND objectId={id} endpoint".
Behavior per spec:
- No owner + assign user → set owner (existing behavior kept)
- Has owner + assign different user → POST /schedule/entries with type/id=4,
member, objectId; owner untouched
- User already assigned (owner or schedule entry) → idempotent no-op
- Remove owner → clear owner (existing behavior kept)
- Remove co-assignee → DELETE /schedule/entries/{entry_id}
- list_resources now merges owner + schedule-entry members, deduped by id
Required CW security role permission on the API member:
- Service > Resource Scheduling > Add/Inquire/Delete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -648,33 +648,46 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
|
|
||||||
# ── Resource management ───────────────────────────────────────────
|
# ── Resource management ───────────────────────────────────────────
|
||||||
|
|
||||||
async def _get_ticket_assignment(self, ticket_id: int) -> tuple[dict | None, str | None]:
|
# Schedule type id for "Service Ticket" resources — CW's canonical type for ticket co-assignees
|
||||||
"""Fetch the ticket's current owner (MemberReference) and resources string.
|
_SCHEDULE_TYPE_SERVICE_TICKET = 4
|
||||||
|
|
||||||
Returns (owner_dict, resources_string). owner_dict has keys like
|
async def _get_ticket_owner(self, ticket_id: int) -> dict | None:
|
||||||
{id, identifier, name} when set; None when unassigned.
|
"""Fetch the ticket's current owner (MemberReference) or None if unassigned."""
|
||||||
"""
|
|
||||||
data = await self.client.get(
|
data = await self.client.get(
|
||||||
f"/service/tickets/{ticket_id}",
|
f"/service/tickets/{ticket_id}",
|
||||||
params={"fields": "id,owner,resources"},
|
params={"fields": "id,owner"},
|
||||||
)
|
)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return None, None
|
return None
|
||||||
owner_raw = data.get("owner")
|
owner_raw = data.get("owner")
|
||||||
owner = owner_raw if isinstance(owner_raw, dict) and owner_raw.get("id") else None
|
return 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_ticket_schedule_entries(self, ticket_id: int) -> list[dict]:
|
||||||
|
"""List schedule entries for a ticket's co-assignees.
|
||||||
|
|
||||||
|
Returns raw CW schedule entry dicts with at least id and member info.
|
||||||
|
"""
|
||||||
|
data = await self.client.get(
|
||||||
|
"/schedule/entries",
|
||||||
|
params={
|
||||||
|
"conditions": (
|
||||||
|
f"type/id={self._SCHEDULE_TYPE_SERVICE_TICKET} AND objectId={ticket_id}"
|
||||||
|
),
|
||||||
|
"fields": "id,member,name",
|
||||||
|
"pageSize": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
"""List members assigned to a CW ticket.
|
"""List members assigned to a CW ticket.
|
||||||
|
|
||||||
CW exposes assignment in two places: the `owner` MemberReference (primary
|
Merges the `owner` MemberReference (primary assignee) with schedule entries
|
||||||
assignee) and a derived `resources` string of identifiers. We merge both
|
of type 4 (Service Ticket resources — co-assignees). Deduped by member id.
|
||||||
so the UI shows everyone CW considers assigned.
|
|
||||||
"""
|
"""
|
||||||
owner, resources_str = await self._get_ticket_assignment(ticket_id)
|
owner = await self._get_ticket_owner(ticket_id)
|
||||||
|
entries = await self._list_ticket_schedule_entries(ticket_id)
|
||||||
members = await self.list_members()
|
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}
|
by_id = {str(m.id): m for m in members}
|
||||||
|
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
@@ -689,48 +702,55 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
member_name=m.name,
|
member_name=m.name,
|
||||||
member_identifier=m.identifier,
|
member_identifier=m.identifier,
|
||||||
))
|
))
|
||||||
seen_ids.add(owner_id)
|
|
||||||
else:
|
else:
|
||||||
results.append(PSAResource(
|
results.append(PSAResource(
|
||||||
member_id=int(owner.get("id") or 0),
|
member_id=int(owner.get("id") or 0),
|
||||||
member_name=str(owner.get("name") or ""),
|
member_name=str(owner.get("name") or ""),
|
||||||
member_identifier=str(owner.get("identifier") or ""),
|
member_identifier=str(owner.get("identifier") or ""),
|
||||||
))
|
))
|
||||||
seen_ids.add(owner_id)
|
seen_ids.add(owner_id)
|
||||||
|
|
||||||
if resources_str:
|
for entry in entries:
|
||||||
for ident in (p.strip() for p in resources_str.split(",") if p.strip()):
|
entry_member = entry.get("member") if isinstance(entry, dict) else None
|
||||||
m = by_identifier.get(ident)
|
if not isinstance(entry_member, dict):
|
||||||
if m and str(m.id) not in seen_ids:
|
continue
|
||||||
results.append(PSAResource(
|
mid = str(entry_member.get("id") or "")
|
||||||
member_id=int(m.id),
|
if not mid or mid in seen_ids:
|
||||||
member_name=m.name,
|
continue
|
||||||
member_identifier=m.identifier,
|
m = by_id.get(mid)
|
||||||
))
|
if m:
|
||||||
seen_ids.add(str(m.id))
|
results.append(PSAResource(
|
||||||
elif not m:
|
member_id=int(m.id),
|
||||||
results.append(PSAResource(
|
member_name=m.name,
|
||||||
member_id=0,
|
member_identifier=m.identifier,
|
||||||
member_name=ident,
|
))
|
||||||
member_identifier=ident,
|
else:
|
||||||
))
|
results.append(PSAResource(
|
||||||
|
member_id=int(entry_member.get("id") or 0),
|
||||||
|
member_name=str(entry_member.get("name") or ""),
|
||||||
|
member_identifier=str(entry_member.get("identifier") or ""),
|
||||||
|
))
|
||||||
|
seen_ids.add(mid)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
"""Assign a member to a CW ticket.
|
"""Assign a member to a CW ticket.
|
||||||
|
|
||||||
CW's `resources` string is derived server-side and PATCHing it is unreliable.
|
- If the ticket has no owner, set the target as `owner` (CW's canonical
|
||||||
The canonical writable field is `owner`. If the ticket is unassigned we set
|
primary assignee field). CW typically mirrors this into the derived
|
||||||
the owner; if it already has a different owner, we append to `resources`
|
`resources` string automatically.
|
||||||
(best-effort — CW may not surface it in every view).
|
- If the ticket is already owned by someone else, add the target as a
|
||||||
|
co-assignee via a schedule entry of type 4 (Service Ticket). The
|
||||||
|
existing owner is not changed.
|
||||||
|
- Idempotent when target is already owner or already has a schedule entry.
|
||||||
"""
|
"""
|
||||||
members = await self.list_members()
|
members = await self.list_members()
|
||||||
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
||||||
if target is None:
|
if target is None:
|
||||||
raise PSAError(f"Member {member_id} not found")
|
raise PSAError(f"Member {member_id} not found")
|
||||||
|
|
||||||
current_owner, resources_str = await self._get_ticket_assignment(ticket_id)
|
current_owner = await self._get_ticket_owner(ticket_id)
|
||||||
|
|
||||||
if current_owner is None:
|
if current_owner is None:
|
||||||
# Primary assign — set owner
|
# Primary assign — set owner
|
||||||
@@ -739,13 +759,22 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
json_body=[{"op": "replace", "path": "owner", "value": {"id": int(target.id)}}],
|
json_body=[{"op": "replace", "path": "owner", "value": {"id": int(target.id)}}],
|
||||||
)
|
)
|
||||||
elif str(current_owner.get("id")) != str(target.id):
|
elif str(current_owner.get("id")) != str(target.id):
|
||||||
# Ticket already owned by someone else — add to resources string as co-assignee.
|
# Ticket owned by someone else — add as co-assignee via schedule entry.
|
||||||
identifiers = [p.strip() for p in (resources_str or "").split(",") if p.strip()]
|
# Idempotent: skip if a schedule entry already exists for this member.
|
||||||
if target.identifier and target.identifier not in identifiers:
|
existing = await self._list_ticket_schedule_entries(ticket_id)
|
||||||
identifiers.append(target.identifier)
|
already_assigned = any(
|
||||||
await self.client.patch(
|
str((e.get("member") or {}).get("id") or "") == str(target.id)
|
||||||
f"/service/tickets/{ticket_id}",
|
for e in existing
|
||||||
json_body=[{"op": "replace", "path": "resources", "value": ",".join(identifiers)}],
|
)
|
||||||
|
if not already_assigned:
|
||||||
|
await self.client.post(
|
||||||
|
"/schedule/entries",
|
||||||
|
json_body={
|
||||||
|
"member": {"id": int(target.id)},
|
||||||
|
"objectId": int(ticket_id),
|
||||||
|
"type": {"id": self._SCHEDULE_TYPE_SERVICE_TICKET},
|
||||||
|
"name": target.name or target.identifier or f"Member {target.id}",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
# else: already the owner — idempotent no-op
|
# else: already the owner — idempotent no-op
|
||||||
|
|
||||||
@@ -758,15 +787,15 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
"""Remove a member from a CW ticket (idempotent).
|
"""Remove a member from a CW ticket (idempotent).
|
||||||
|
|
||||||
If the target is the current owner, clear the owner. Otherwise remove them
|
- If the target is the current owner, clear the owner field.
|
||||||
from the `resources` string.
|
- Otherwise, delete their schedule entry (Service Ticket type).
|
||||||
"""
|
"""
|
||||||
members = await self.list_members()
|
members = await self.list_members()
|
||||||
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
||||||
if target is None:
|
if target is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
current_owner, resources_str = await self._get_ticket_assignment(ticket_id)
|
current_owner = await self._get_ticket_owner(ticket_id)
|
||||||
|
|
||||||
if current_owner is not None and str(current_owner.get("id")) == str(target.id):
|
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
|
# Unassign the owner. Try RFC 6902 "remove" first; fall back to
|
||||||
@@ -783,14 +812,15 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if target.identifier and resources_str:
|
# Not the owner — find and delete the schedule entry for this member.
|
||||||
identifiers = [p.strip() for p in resources_str.split(",") if p.strip()]
|
entries = await self._list_ticket_schedule_entries(ticket_id)
|
||||||
if target.identifier in identifiers:
|
for entry in entries:
|
||||||
new_list = [i for i in identifiers if i != target.identifier]
|
entry_member = entry.get("member") if isinstance(entry, dict) else None
|
||||||
await self.client.patch(
|
if isinstance(entry_member, dict) and str(entry_member.get("id") or "") == str(target.id):
|
||||||
f"/service/tickets/{ticket_id}",
|
entry_id = entry.get("id")
|
||||||
json_body=[{"op": "replace", "path": "resources", "value": ",".join(new_list)}],
|
if entry_id:
|
||||||
)
|
await self.client.delete(f"/schedule/entries/{entry_id}")
|
||||||
|
break
|
||||||
|
|
||||||
# ── Ticket creation ───────────────────────────────────────────────
|
# ── Ticket creation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user