TorbatYar/backend/services/crm/app/models/supporting.py
Mortezakoohjani 064d67f099 Ship CRM Core Platform (phases 6.0–6.3) with docs and prod deploy.
Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 20:32:00 +03:30

362 lines
13 KiB
Python

"""Phase 6.1 supporting CRM entities — lookups, tags, addresses, notes, attachments, audit."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
DateTime,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
AddressType,
AttachmentKind,
AuditAction,
CrmEntityType,
CustomFieldType,
GUID,
TagScope,
)
class LeadSource(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "lead_sources"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(150), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_lead_sources_tenant_code"),
)
class LeadStatusDefinition(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "lead_statuses"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(150), nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_converted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_lead_statuses_tenant_code"),
)
class ContactType(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "contact_types"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(150), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_contact_types_tenant_code"),
)
class OrganizationType(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "organization_types"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(150), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint(
"tenant_id", "code", name="uq_organization_types_tenant_code"
),
)
class OrganizationIndustry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "organization_industries"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(150), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint(
"tenant_id", "code", name="uq_organization_industries_tenant_code"
),
)
class LeadAssignment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "lead_assignments"
lead_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
assigned_user_id: Mapped[str] = mapped_column(String(100), nullable=False)
assigned_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
assigned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
is_current: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
Index("ix_lead_assignments_lead", "tenant_id", "lead_id"),
Index("ix_lead_assignments_user", "tenant_id", "assigned_user_id"),
)
class CrmTag(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "crm_tags"
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
group_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
scope: Mapped[TagScope] = mapped_column(default=TagScope.ANY, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_tags_tenant_name"),
)
class LeadTag(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "lead_tags"
lead_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
tag_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "lead_id", "tag_id", name="uq_lead_tags"),
Index("ix_lead_tags_lead", "tenant_id", "lead_id"),
)
class ContactTag(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "contact_tags"
contact_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
tag_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
__table_args__ = (
UniqueConstraint("tenant_id", "contact_id", "tag_id", name="uq_contact_tags"),
Index("ix_contact_tags_contact", "tenant_id", "contact_id"),
)
class OrganizationTag(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "organization_tags"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
tag_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
__table_args__ = (
UniqueConstraint(
"tenant_id", "organization_id", "tag_id", name="uq_organization_tags"
),
Index("ix_organization_tags_org", "tenant_id", "organization_id"),
)
class CrmCustomField(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "crm_custom_fields"
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
field_key: Mapped[str] = mapped_column(String(100), nullable=False)
label: Mapped[str] = mapped_column(String(150), nullable=False)
field_type: Mapped[CustomFieldType] = mapped_column(nullable=False)
is_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
options: Mapped[list | None] = mapped_column(JSON, nullable=True)
validation_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"entity_type",
"field_key",
name="uq_crm_custom_fields_key",
),
Index("ix_crm_custom_fields_entity", "tenant_id", "entity_type"),
)
class CrmCustomFieldValue(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "crm_custom_field_values"
custom_field_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
value_text: Mapped[str | None] = mapped_column(Text, nullable=True)
value_number: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
value_boolean: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
value_date: Mapped[date | None] = mapped_column(Date, nullable=True)
value_json: Mapped[list | dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"custom_field_id",
"entity_id",
name="uq_crm_custom_field_values",
),
Index(
"ix_crm_custom_field_values_entity",
"tenant_id",
"entity_type",
"entity_id",
),
)
class CrmAddress(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "crm_addresses"
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
address_type: Mapped[AddressType] = mapped_column(nullable=False)
label: Mapped[str | None] = mapped_column(String(100), nullable=True)
line1: Mapped[str] = mapped_column(String(255), nullable=False)
line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
postal_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
country: Mapped[str | None] = mapped_column(String(100), nullable=True)
latitude: Mapped[Decimal | None] = mapped_column(Numeric(10, 7), nullable=True)
longitude: Mapped[Decimal | None] = mapped_column(Numeric(10, 7), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (
Index(
"ix_crm_addresses_entity",
"tenant_id",
"entity_type",
"entity_id",
),
)
class CrmNote(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "crm_notes"
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
body_format: Mapped[str] = mapped_column(String(20), default="richtext", nullable=False)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
__table_args__ = (
Index(
"ix_crm_notes_entity",
"tenant_id",
"entity_type",
"entity_id",
),
)
class CrmNoteVersion(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "crm_note_versions"
note_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "note_id", "version", name="uq_crm_note_versions"
),
Index("ix_crm_note_versions_note", "tenant_id", "note_id"),
)
class CrmAttachment(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Reference to File Storage service — no binary bytes stored in CRM."""
__tablename__ = "crm_attachments"
entity_type: Mapped[CrmEntityType] = mapped_column(nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
file_storage_id: Mapped[str] = mapped_column(String(100), nullable=False)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[str | None] = mapped_column(String(150), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
kind: Mapped[AttachmentKind] = mapped_column(
default=AttachmentKind.DOCUMENT, nullable=False
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index(
"ix_crm_attachments_entity",
"tenant_id",
"entity_type",
"entity_id",
),
Index("ix_crm_attachments_file", "tenant_id", "file_storage_id"),
)
class CrmAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
__tablename__ = "crm_audit"
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
action: Mapped[AuditAction] = mapped_column(nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index(
"ix_crm_audit_entity",
"tenant_id",
"entity_type",
"entity_id",
),
Index("ix_crm_audit_action", "tenant_id", "action"),
)