feat: ConnectWise PSA integration (#106)

PSA abstraction layer with provider pattern, ConnectWise integration (connection management, ticket linking, note posting, status updates, member mapping), Integrations page UI, Fernet credential encryption, in-memory TTL cache, 6 DB migrations, ConnectWise API reference docs.
This commit was merged in pull request #106.
This commit is contained in:
chihlasm
2026-03-15 01:45:35 -04:00
committed by GitHub
parent 80e094215f
commit 46865882c6
60 changed files with 726716 additions and 11 deletions

View File

@@ -23,8 +23,12 @@ from app.schemas.session import (
SessionComplete,
SessionVariablesUpdate,
PrepareSessionRequest,
TicketLinkRequest,
TicketLinkResponse,
PSATicketResponse,
)
from app.api.deps import get_current_active_user
from app.schemas.psa_connection import PsaPostRequest
from app.api.deps import get_current_active_user, require_engineer_or_admin
from app.core.permissions import can_access_tree
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
@@ -738,3 +742,382 @@ async def batch_launch_sessions(
for s in created_sessions
],
)
# ── PSA Ticket Link ─────────────────────────────────────────────────
@router.patch("/{session_id}/ticket-link", response_model=TicketLinkResponse)
async def link_ticket(
session_id: UUID,
data: TicketLinkRequest,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Link or unlink a PSA ticket to/from a session."""
from app.models.psa_connection import PsaConnection
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSANotFoundError, PSAError
# Look up session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found",
)
# Verify ownership or admin
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this session",
)
# Unlink
if data.psa_ticket_id is None:
session.psa_ticket_id = None
session.psa_connection_id = None
await db.commit()
return TicketLinkResponse(
session_id=str(session.id),
psa_ticket_id=None,
ticket=None,
)
# Link — validate ticket exists in CW
if not current_user.account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No account associated with your user",
)
try:
provider = await get_provider_for_account(current_user.account_id, db)
except PSAError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# Fetch the connection to store its ID
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
psa_connection = conn_result.scalar_one_or_none()
try:
ticket = await provider.get_ticket(data.psa_ticket_id)
except PSANotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found in ConnectWise",
)
except PSAError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"PSA error: {exc}",
)
session.psa_ticket_id = ticket.id
session.psa_connection_id = psa_connection.id if psa_connection else None
await db.commit()
return TicketLinkResponse(
session_id=str(session.id),
psa_ticket_id=ticket.id,
ticket=PSATicketResponse(
id=ticket.id,
summary=ticket.summary,
company_name=ticket.company_name,
board_name=ticket.board_name,
status_name=ticket.status_name,
priority_name=ticket.priority_name,
),
)
# ── PSA Post to Ticket ────────────────────────────────────────────
@router.get("/{session_id}/psa-post/preview")
async def psa_post_preview(
session_id: UUID,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Preview the content that will be posted to the linked PSA ticket.
Generates session documentation in PSA format, fetches current ticket
details and available statuses, and counts previous posts.
"""
from app.models.psa_post_log import PsaPostLog
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
from app.schemas.psa_connection import (
PsaPreviewResponse,
PSATicketSearchResult,
PSATicketStatusItem,
)
from sqlalchemy import func as sa_func
# Load session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
if not session.psa_ticket_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Session has no linked PSA ticket. Link a ticket first.",
)
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account associated with your user")
# Generate PSA export content
export_options = SessionExport(
format="psa",
include_timestamps=True,
include_tree_info=True,
include_outcome_notes=True,
include_next_steps=True,
include_summary=True,
)
content = generate_psa_export(session, export_options)
# Resolve session variables in content
session_vars = getattr(session, "session_variables", None) or {}
if session_vars:
from app.services.variable_service import resolve_variables
content = resolve_variables(content, session_vars)
# Fetch ticket details and statuses from CW
try:
provider = await get_provider_for_account(current_user.account_id, db)
ticket = await provider.get_ticket(session.psa_ticket_id)
available_statuses: list[PSATicketStatusItem] = []
if ticket.board_id:
statuses = await provider.get_ticket_statuses(ticket.board_id)
available_statuses = [
PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed)
for s in statuses
]
except PSAError as e:
raise HTTPException(status_code=502, detail=f"PSA error: {e}")
# Count previous posts
count_result = await db.execute(
select(sa_func.count(PsaPostLog.id)).where(
PsaPostLog.session_id == session_id
)
)
previous_posts = count_result.scalar_one()
return PsaPreviewResponse(
content=content,
ticket=PSATicketSearchResult(
id=ticket.id,
summary=ticket.summary,
company_name=ticket.company_name,
board_name=ticket.board_name,
status_name=ticket.status_name,
priority_name=ticket.priority_name,
closed=ticket.closed,
),
available_statuses=available_statuses,
character_count=len(content),
previous_posts=previous_posts,
)
@router.post("/{session_id}/psa-post")
async def psa_post_to_ticket(
session_id: UUID,
data: PsaPostRequest,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Post session documentation as a note to the linked PSA ticket.
Optionally updates the ticket status if update_status_id is provided.
All actions are logged in psa_post_log for audit trail.
"""
from app.models.psa_connection import PsaConnection
from app.models.psa_post_log import PsaPostLog
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
from app.schemas.psa_connection import PsaPostResponse
# Load session
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
if not session.psa_ticket_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Session has no linked PSA ticket. Link a ticket first.",
)
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account associated with your user")
# Get PSA connection ID for audit
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
psa_connection = conn_result.scalar_one_or_none()
# Look up member mapping for attribution
from app.models.psa_member_mapping import PsaMemberMapping
member_id = None
if psa_connection:
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == psa_connection.id,
PsaMemberMapping.user_id == current_user.id,
)
)
mapping = mapping_result.scalar_one_or_none()
if mapping:
member_id = mapping.external_member_id
# Post note
try:
provider = await get_provider_for_account(current_user.account_id, db)
note_result = await provider.post_note(
ticket_id=session.psa_ticket_id,
text=data.content,
note_type=data.note_type,
member_id=member_id,
)
note_status = "success"
external_note_id = note_result.id
error_message = None
except PSAError as e:
note_status = "failed"
external_note_id = None
error_message = str(e)
# Optionally update ticket status
status_changed_from = None
status_changed_to = None
if data.update_status_id and note_status == "success":
try:
# Get current status before update
current_ticket = await provider.get_ticket(session.psa_ticket_id)
status_changed_from = current_ticket.status_name
if current_ticket.status_id != data.update_status_id:
updated_ticket = await provider.update_ticket_status(
session.psa_ticket_id, data.update_status_id
)
status_changed_to = updated_ticket.status_name
except PSAError as e:
# Log the status update failure but don't fail the whole request
# since the note was already posted successfully
if error_message:
error_message += f"; Status update failed: {e}"
else:
error_message = f"Note posted successfully but status update failed: {e}"
# Log to audit trail
log_entry = PsaPostLog(
session_id=session.id,
psa_connection_id=psa_connection.id if psa_connection else None,
ticket_id=session.psa_ticket_id,
note_type=data.note_type,
content_posted=data.content,
external_note_id=external_note_id,
status=note_status,
error_message=error_message,
status_changed_from=status_changed_from,
status_changed_to=status_changed_to,
posted_by=current_user.id,
)
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
if note_status == "failed":
raise HTTPException(
status_code=502,
detail=error_message or "Failed to post note to PSA",
)
return PsaPostResponse(
id=str(log_entry.id),
session_id=str(session.id),
ticket_id=session.psa_ticket_id,
note_type=data.note_type,
status=note_status,
external_note_id=external_note_id,
error_message=error_message,
status_changed_from=status_changed_from,
status_changed_to=status_changed_to,
posted_at=log_entry.posted_at.isoformat(),
)
@router.get("/{session_id}/psa-posts")
async def list_psa_posts(
session_id: UUID,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all PSA post history for a session, ordered by most recent first."""
from app.models.psa_post_log import PsaPostLog
from app.schemas.psa_connection import PsaPostLogResponse
# Verify session access
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
if not current_user.is_super_admin:
raise HTTPException(status_code=403, detail="You don't have access to this session")
# Query post log
log_result = await db.execute(
select(PsaPostLog)
.where(PsaPostLog.session_id == session_id)
.order_by(PsaPostLog.posted_at.desc())
)
logs = log_result.scalars().all()
return [
PsaPostLogResponse(
id=str(log.id),
ticket_id=log.ticket_id,
note_type=log.note_type,
status=log.status,
error_message=log.error_message,
status_changed_from=log.status_changed_from,
status_changed_to=log.status_changed_to,
posted_at=log.posted_at.isoformat(),
content_preview=log.content_posted[:200],
)
for log in logs
]