|
|
|
|
@@ -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(
|
|
|
|
|
|