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:
@@ -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",
|
||||
]
|
||||
|
||||
138
backend/app/schemas/psa_connection.py
Normal file
138
backend/app/schemas/psa_connection.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user