feat(psa): add ticket search and status API endpoints
Add three new endpoints under /integrations/psa:
- GET /tickets/search: search CW tickets with query, board_id, status_id filters
- GET /tickets/{ticket_id}: fetch a single ticket by ID
- GET /tickets/{ticket_id}/statuses: get available statuses for a ticket's board
Add PSATicketSearchResult and PSATicketStatusItem schemas.
All endpoints require engineer_or_admin auth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, require_account_owner
|
||||
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
||||
from app.core.database import get_db
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.user import User
|
||||
@@ -18,6 +18,8 @@ from app.schemas.psa_connection import (
|
||||
PsaConnectionResponse,
|
||||
PsaConnectionTestResponse,
|
||||
PsaConnectionUpdate,
|
||||
PSATicketSearchResult,
|
||||
PSATicketStatusItem,
|
||||
)
|
||||
from app.services.psa.encryption import (
|
||||
decrypt_credentials,
|
||||
@@ -260,10 +262,102 @@ async def test_connection(
|
||||
if result.success:
|
||||
conn.last_validated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
# Invalidate cached PSA data when connection is re-validated
|
||||
from app.services.psa.cache import psa_cache
|
||||
psa_cache.clear()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── ticket / status / company endpoints ──────────────────────────
|
||||
|
||||
|
||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
||||
async def search_tickets(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
query: str = "",
|
||||
board_id: int | None = None,
|
||||
status_id: int | None = None,
|
||||
include_closed: bool = False,
|
||||
):
|
||||
"""Search ConnectWise tickets."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
tickets = await provider.search_tickets(
|
||||
query, board_id=board_id, status_id=status_id, include_closed=include_closed
|
||||
)
|
||||
return [
|
||||
PSATicketSearchResult(
|
||||
id=t.id,
|
||||
summary=t.summary,
|
||||
company_name=t.company_name,
|
||||
board_name=t.board_name,
|
||||
status_name=t.status_name,
|
||||
priority_name=t.priority_name,
|
||||
closed=t.closed,
|
||||
)
|
||||
for t in tickets
|
||||
]
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tickets/{ticket_id}")
|
||||
async def get_ticket(
|
||||
ticket_id: str,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get a single CW ticket by ID."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError, PSANotFoundError
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
ticket = await provider.get_ticket(ticket_id)
|
||||
return ticket
|
||||
except PSANotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tickets/{ticket_id}/statuses", response_model=list[PSATicketStatusItem])
|
||||
async def get_ticket_statuses(
|
||||
ticket_id: str,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get available statuses for a ticket's board."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError, PSANotFoundError
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
ticket = await provider.get_ticket(ticket_id)
|
||||
if not ticket.board_id:
|
||||
raise HTTPException(status_code=400, detail="Ticket has no board")
|
||||
statuses = await provider.get_ticket_statuses(ticket.board_id)
|
||||
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
|
||||
except PSANotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
# ── internal helpers ─────────────────────────────────────────────────
|
||||
|
||||
async def _get_connection_or_404(
|
||||
|
||||
@@ -17,6 +17,7 @@ from .script_template import (
|
||||
)
|
||||
from .psa_connection import (
|
||||
PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse,
|
||||
PSATicketSearchResult, PSATicketStatusItem,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -44,4 +45,5 @@ __all__ = [
|
||||
"ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord",
|
||||
# PSA Connection
|
||||
"PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse",
|
||||
"PSATicketSearchResult", "PSATicketStatusItem",
|
||||
]
|
||||
|
||||
@@ -45,3 +45,22 @@ class PsaConnectionTestResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
server_version: str | None = None
|
||||
|
||||
|
||||
# ── Ticket search & status schemas ────────────────────────────────
|
||||
|
||||
|
||||
class PSATicketSearchResult(BaseModel):
|
||||
id: str
|
||||
summary: str
|
||||
company_name: str | None = None
|
||||
board_name: str | None = None
|
||||
status_name: str | None = None
|
||||
priority_name: str | None = None
|
||||
closed: bool = False
|
||||
|
||||
|
||||
class PSATicketStatusItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
is_closed: bool = False
|
||||
|
||||
Reference in New Issue
Block a user