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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.core.database import get_db
|
||||||
from app.models.psa_connection import PsaConnection
|
from app.models.psa_connection import PsaConnection
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -18,6 +18,8 @@ from app.schemas.psa_connection import (
|
|||||||
PsaConnectionResponse,
|
PsaConnectionResponse,
|
||||||
PsaConnectionTestResponse,
|
PsaConnectionTestResponse,
|
||||||
PsaConnectionUpdate,
|
PsaConnectionUpdate,
|
||||||
|
PSATicketSearchResult,
|
||||||
|
PSATicketStatusItem,
|
||||||
)
|
)
|
||||||
from app.services.psa.encryption import (
|
from app.services.psa.encryption import (
|
||||||
decrypt_credentials,
|
decrypt_credentials,
|
||||||
@@ -260,10 +262,102 @@ async def test_connection(
|
|||||||
if result.success:
|
if result.success:
|
||||||
conn.last_validated_at = datetime.now(timezone.utc)
|
conn.last_validated_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
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
|
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 ─────────────────────────────────────────────────
|
# ── internal helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _get_connection_or_404(
|
async def _get_connection_or_404(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .script_template import (
|
|||||||
)
|
)
|
||||||
from .psa_connection import (
|
from .psa_connection import (
|
||||||
PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse,
|
PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse,
|
||||||
|
PSATicketSearchResult, PSATicketStatusItem,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -44,4 +45,5 @@ __all__ = [
|
|||||||
"ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord",
|
"ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord",
|
||||||
# PSA Connection
|
# PSA Connection
|
||||||
"PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse",
|
"PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse",
|
||||||
|
"PSATicketSearchResult", "PSATicketStatusItem",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -45,3 +45,22 @@ class PsaConnectionTestResponse(BaseModel):
|
|||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
server_version: str | None = None
|
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