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

@@ -36,6 +36,9 @@ from .survey_response import SurveyResponse
from .survey_invite import SurveyInvite
from .kb_import import KBImport, KBImportNode
from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
from .psa_connection import PsaConnection
from .psa_post_log import PsaPostLog
from .psa_member_mapping import PsaMemberMapping
__all__ = [
"User",
@@ -86,4 +89,7 @@ __all__ = [
"ScriptCategory",
"ScriptTemplate",
"ScriptGeneration",
"PsaConnection",
"PsaPostLog",
"PsaMemberMapping",
]

View File

@@ -15,6 +15,7 @@ if TYPE_CHECKING:
from app.models.step_category import StepCategory
from app.models.step_library import StepLibrary
from app.models.account_limit_override import AccountLimitOverride
from app.models.psa_connection import PsaConnection
class Account(Base):
@@ -53,3 +54,4 @@ class Account(Base):
step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account")
step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account")
limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False)
psa_connection: Mapped[Optional["PsaConnection"]] = relationship("PsaConnection", back_populates="account", uselist=False)

View File

@@ -0,0 +1,48 @@
"""PSA connection model — one per account."""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class PsaConnection(Base):
__tablename__ = "psa_connections"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
site_url: Mapped[str] = mapped_column(String(255), nullable=False)
company_id: Mapped[str] = mapped_column(String(100), nullable=False)
credentials_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
last_validated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# Relationships
account = relationship("Account", back_populates="psa_connection")

View File

@@ -0,0 +1,47 @@
"""Maps ResolutionFlow users to CW members."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class PsaMemberMapping(Base):
__tablename__ = "psa_member_mappings"
__table_args__ = (
UniqueConstraint("psa_connection_id", "user_id", name="uq_psa_member_mapping_connection_user"),
UniqueConstraint("psa_connection_id", "external_member_id", name="uq_psa_member_mapping_connection_member"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
psa_connection_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("psa_connections.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
external_member_id: Mapped[str] = mapped_column(String(100), nullable=False)
external_member_name: Mapped[str] = mapped_column(String(200), nullable=False)
matched_by: Mapped[str] = mapped_column(String(50), nullable=False) # 'auto_email', 'manual_admin', 'manual_self'
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# Relationships
psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id])
user = relationship("User", foreign_keys=[user_id])

View File

@@ -0,0 +1,58 @@
"""Audit trail for notes posted to PSA systems."""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class PsaPostLog(Base):
__tablename__ = "psa_post_log"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
)
ticket_id: Mapped[str] = mapped_column(String(100), nullable=False)
note_type: Mapped[str] = mapped_column(String(50), nullable=False)
content_posted: Mapped[str] = mapped_column(Text, nullable=False)
external_note_id: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
status: Mapped[str] = mapped_column(
String(20), nullable=False
) # 'success' or 'failed'
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status_changed_from: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
status_changed_to: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
posted_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
)
posted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
# Relationships
session = relationship("Session", foreign_keys=[session_id])
psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id])
user = relationship("User", foreign_keys=[posted_by])

View File

@@ -83,6 +83,15 @@ class Session(Base):
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
# PSA ticket link
psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
)
psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id])
# Batch tracking (maintenance flows)
batch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), nullable=True, index=True