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

@@ -15,6 +15,12 @@ from .script_template import (
ScriptTemplateCreate, ScriptTemplateUpdate, ScriptTemplateListItem, ScriptTemplateDetail,
ScriptGenerateRequest, ScriptGenerateResponse, ScriptGenerationRecord,
)
from .psa_connection import (
PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionResponse, PsaConnectionTestResponse,
PSATicketSearchResult, PSATicketStatusItem,
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
)
__all__ = [
# User
@@ -39,4 +45,9 @@ __all__ = [
"ScriptCategoryResponse",
"ScriptTemplateCreate", "ScriptTemplateUpdate", "ScriptTemplateListItem", "ScriptTemplateDetail",
"ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord",
# PSA Connection
"PsaConnectionCreate", "PsaConnectionUpdate", "PsaConnectionResponse", "PsaConnectionTestResponse",
"PSATicketSearchResult", "PSATicketStatusItem",
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
]

View File

@@ -0,0 +1,138 @@
"""Pydantic schemas for PSA connection management."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class PsaConnectionCreate(BaseModel):
provider: str = Field(default="connectwise", pattern="^(connectwise|autotask)$")
display_name: str = Field(min_length=1, max_length=100)
site_url: str = Field(min_length=1, max_length=255)
company_id: str = Field(min_length=1, max_length=100)
public_key: str = Field(min_length=1)
private_key: str = Field(min_length=1)
# Note: client_id is NOT per-MSP — it's a product-level GUID from settings.CW_CLIENT_ID
class PsaConnectionUpdate(BaseModel):
display_name: str | None = None
site_url: str | None = None
company_id: str | None = None
public_key: str | None = None
private_key: str | None = None
class PsaConnectionResponse(BaseModel):
id: UUID
account_id: UUID
provider: str
display_name: str
site_url: str
company_id: str
is_active: bool
last_validated_at: datetime | None
created_at: datetime
updated_at: datetime
public_key_hint: str
private_key_hint: str
model_config = {"from_attributes": True}
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
# ── PSA post (note posting) schemas ──────────────────────────────
class PsaPostRequest(BaseModel):
note_type: str = Field(pattern="^(internal_analysis|resolution|description)$")
content: str = Field(min_length=1)
update_status_id: int | None = None
class PsaPostResponse(BaseModel):
id: str
session_id: str
ticket_id: str
note_type: str
status: str
external_note_id: str | None = None
error_message: str | None = None
status_changed_from: str | None = None
status_changed_to: str | None = None
posted_at: str
class PsaPreviewResponse(BaseModel):
content: str
ticket: PSATicketSearchResult
available_statuses: list[PSATicketStatusItem]
character_count: int
previous_posts: int
class PsaPostLogResponse(BaseModel):
id: str
ticket_id: str
note_type: str
status: str
error_message: str | None = None
status_changed_from: str | None = None
status_changed_to: str | None = None
posted_at: str
content_preview: str # first 200 chars
# ── Member mapping schemas ───────────────────────────────────────
class PsaMemberMappingResponse(BaseModel):
id: str
user_id: str
user_email: str
user_name: str
external_member_id: str
external_member_name: str
matched_by: str
class PsaMemberMappingSaveRequest(BaseModel):
user_id: str
external_member_id: str
external_member_name: str
class PsaMemberResponse(BaseModel):
id: str
identifier: str
name: str
email: str | None = None
class AutoMatchResult(BaseModel):
matched: list[PsaMemberMappingResponse]
unmatched_users: int

View File

@@ -94,6 +94,10 @@ class SessionResponse(BaseModel):
batch_id: Optional[UUID] = None
target_label: Optional[str] = None
# PSA ticket link
psa_ticket_id: Optional[str] = None
psa_connection_id: Optional[UUID] = None
class Config:
from_attributes = True
@@ -140,3 +144,28 @@ class SaveAsTreeResponse(BaseModel):
tree_id: UUID
tree_name: str
message: str
# ── PSA ticket link ──────────────────────────────────────────────────
class TicketLinkRequest(BaseModel):
"""Link or unlink a PSA ticket to a session."""
psa_ticket_id: Optional[str] = None # null to unlink
class PSATicketResponse(BaseModel):
"""PSA ticket details returned when linking."""
id: str
summary: str
company_name: Optional[str] = None
board_name: Optional[str] = None
status_name: Optional[str] = None
priority_name: Optional[str] = None
class TicketLinkResponse(BaseModel):
"""Response after linking/unlinking a ticket."""
session_id: str
psa_ticket_id: Optional[str] = None
ticket: Optional[PSATicketResponse] = None