fix(tickets): status filter dropdown and CW resource assignment
Some checks failed
Mirror to GitHub / mirror (push) Successful in 4s
CI / backend (pull_request) Failing after 17m51s
CI / frontend (pull_request) Failing after 52s
CI / e2e (pull_request) Has been skipped

- Status filter: aggregate statuses across all boards (deduped by name)
  when no board is selected. Backend accepts status_name and filters by
  status/name so the same status matches across boards.
- Resource assignment: CW has no /service/tickets/{id}/members endpoint —
  assignees live in the ticket's comma-separated `resources` string field.
  Rewrote list/add/remove to read/PATCH that field via member identifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 21:03:00 +00:00
parent bea34229d6
commit 60851b400a
4 changed files with 101 additions and 33 deletions

View File

@@ -389,6 +389,7 @@ async def search_tickets(
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
status_name: str | None = None,
include_closed: bool = False,
assigned_to_me: bool = False,
unassigned: bool = False,
@@ -448,6 +449,7 @@ async def search_tickets(
query,
board_id=board_id,
status_id=status_id,
status_name=status_name,
include_closed=include_closed,
member_identifier=member_identifier,
unassigned=unassigned,

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timezone
from app.services.psa.base import PSAProvider
from app.services.psa.cache import psa_cache
from app.services.psa.exceptions import PSAError
from app.services.psa.types import (
ConnectionTestResult,
PSATicket,
@@ -81,6 +82,9 @@ class ConnectWiseProvider(PSAProvider):
conditions.append(f"board/id = {filters['board_id']}")
if filters.get("status_id"):
conditions.append(f"status/id = {filters['status_id']}")
elif filters.get("status_name"):
safe_status = str(filters["status_name"]).replace("'", "")
conditions.append(f"status/name = '{safe_status}'")
if not filters.get("include_closed", False):
conditions.append("closedFlag = false")
if filters.get("member_identifier") is not None:
@@ -627,44 +631,81 @@ 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)."""
data = await self.client.get(
f"/service/tickets/{ticket_id}",
params={"fields": "id,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()]
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", ""),
))
"""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 []
members = await self.list_members()
by_identifier = {m.identifier: m for m in members if m.identifier}
results: list[PSAResource] = []
for ident in identifiers:
m = by_identifier.get(ident)
if m:
results.append(PSAResource(
member_id=int(m.id),
member_name=m.name,
member_identifier=m.identifier,
))
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,
))
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}},
"""Assign a member to a CW ticket by updating the ticket's `resources` field."""
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")
current = await self._get_ticket_resource_identifiers(ticket_id)
if target.identifier not in current:
current.append(target.identifier)
new_value = ",".join(current)
await self.client.patch(
f"/service/tickets/{ticket_id}",
json_body=[{"op": "replace", "path": "resources", "value": new_value}],
)
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", ""),
member_id=int(target.id),
member_name=target.name,
member_identifier=target.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:
"""Remove a member from a CW ticket by updating the `resources` field (idempotent)."""
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
current = await self._get_ticket_resource_identifiers(ticket_id)
if target.identifier not in current:
return # Already not assigned — idempotent
await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}")
new_list = [i for i in current if i != target.identifier]
new_value = ",".join(new_list)
await self.client.patch(
f"/service/tickets/{ticket_id}",
json_body=[{"op": "replace", "path": "resources", "value": new_value}],
)
# ── Ticket creation ───────────────────────────────────────────────