Files
maskanx_crm_backend/app/models.py
T
AFFAANhandClaude Opus 5 f6a54d6f93 Initial commit: Maskan CRM backend
Independent FastAPI backend for Maskan CRM.

Owns contacts, organizations, leads, pipelines, activities, products,
quotes, users, permissions, audit records and first-party integration
credentials, with Alembic migrations against PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:28:41 +05:30

561 lines
22 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from uuid import uuid4
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
def new_id() -> str:
return str(uuid4())
def now_utc() -> datetime:
return datetime.now(UTC)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
onupdate=now_utc,
nullable=False,
)
class Tenant(Base, TimestampMixin):
__tablename__ = "crm_tenants"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(160), nullable=False)
mode: Mapped[str] = mapped_column(String(24), default="standalone", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class User(Base, TimestampMixin):
__tablename__ = "crm_users"
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_crm_users_tenant_email"),
Index("ix_crm_users_tenant_role", "tenant_id", "role"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(160), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(24), default="member", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tenant: Mapped[Tenant] = relationship()
class Organization(Base, TimestampMixin):
__tablename__ = "crm_organizations"
__table_args__ = (
Index("ix_crm_organizations_tenant_name", "tenant_id", "name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
legal_name: Mapped[str | None] = mapped_column(String(240))
website: Mapped[str | None] = mapped_column(String(500))
primary_email: Mapped[str | None] = mapped_column(String(255))
phone: Mapped[str | None] = mapped_column(String(80))
industry: Mapped[str | None] = mapped_column(String(120))
address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
owner: Mapped[User | None] = relationship()
contacts: Mapped[list[Contact]] = relationship(back_populates="organization")
class Contact(Base, TimestampMixin):
__tablename__ = "crm_contacts"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"primary_email",
name="uq_crm_contacts_tenant_primary_email",
),
Index("ix_crm_contacts_tenant_name", "tenant_id", "last_name", "first_name"),
Index("ix_crm_contacts_tenant_score", "tenant_id", "score"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), default="", nullable=False)
job_title: Mapped[str | None] = mapped_column(String(160))
primary_email: Mapped[str | None] = mapped_column(String(255))
emails: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
phones: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
lifecycle_stage: Mapped[str] = mapped_column(String(40), default="lead", nullable=False)
lead_source: Mapped[str | None] = mapped_column(String(120))
score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
organization_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_organizations.id", ondelete="SET NULL"),
index=True,
)
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
organization: Mapped[Organization | None] = relationship(back_populates="contacts")
owner: Mapped[User | None] = relationship()
@property
def name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()
class Pipeline(Base, TimestampMixin):
__tablename__ = "crm_pipelines"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_pipelines_tenant_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(140), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
stages: Mapped[list[Stage]] = relationship(
back_populates="pipeline",
cascade="all, delete-orphan",
order_by="Stage.position",
)
class Stage(Base, TimestampMixin):
__tablename__ = "crm_stages"
__table_args__ = (
UniqueConstraint(
"pipeline_id",
"name",
name="uq_crm_stages_pipeline_name",
),
Index("ix_crm_stages_pipeline_position", "pipeline_id", "position"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
pipeline_id: Mapped[str] = mapped_column(
ForeignKey("crm_pipelines.id", ondelete="CASCADE"),
nullable=False,
)
name: Mapped[str] = mapped_column(String(140), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
probability: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
color: Mapped[str] = mapped_column(String(16), default="#64748b", nullable=False)
pipeline: Mapped[Pipeline] = relationship(back_populates="stages")
class LeadSource(Base, TimestampMixin):
__tablename__ = "crm_lead_sources"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_lead_sources_tenant_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
class LeadType(Base, TimestampMixin):
__tablename__ = "crm_lead_types"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_lead_types_tenant_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
class Lead(Base, TimestampMixin):
__tablename__ = "crm_leads"
__table_args__ = (
Index("ix_crm_leads_tenant_stage", "tenant_id", "stage_id", "position"),
Index("ix_crm_leads_tenant_status", "tenant_id", "status"),
Index("ix_crm_leads_tenant_owner", "tenant_id", "owner_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
title: Mapped[str] = mapped_column(String(240), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
value: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False)
status: Mapped[str] = mapped_column(String(24), default="open", nullable=False)
score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
lost_reason: Mapped[str | None] = mapped_column(Text)
expected_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
contact_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_contacts.id", ondelete="SET NULL"),
index=True,
)
organization_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_organizations.id", ondelete="SET NULL"),
index=True,
)
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
pipeline_id: Mapped[str] = mapped_column(
ForeignKey("crm_pipelines.id", ondelete="RESTRICT"),
nullable=False,
)
stage_id: Mapped[str] = mapped_column(
ForeignKey("crm_stages.id", ondelete="RESTRICT"),
nullable=False,
)
source_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_lead_sources.id", ondelete="SET NULL"),
)
type_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_lead_types.id", ondelete="SET NULL"),
)
attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
contact: Mapped[Contact | None] = relationship()
organization: Mapped[Organization | None] = relationship()
owner: Mapped[User | None] = relationship()
pipeline: Mapped[Pipeline] = relationship()
stage: Mapped[Stage] = relationship()
source: Mapped[LeadSource | None] = relationship()
lead_type: Mapped[LeadType | None] = relationship()
class Activity(Base, TimestampMixin):
__tablename__ = "crm_activities"
__table_args__ = (
Index("ix_crm_activities_tenant_due", "tenant_id", "is_done", "due_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
activity_type: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str] = mapped_column(String(240), nullable=False)
details: Mapped[str | None] = mapped_column(Text)
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
is_done: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
contact_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_contacts.id", ondelete="CASCADE"),
index=True,
)
organization_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_organizations.id", ondelete="CASCADE"),
index=True,
)
lead_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_leads.id", ondelete="CASCADE"),
index=True,
)
additional: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
owner: Mapped[User | None] = relationship()
contact: Mapped[Contact | None] = relationship()
organization: Mapped[Organization | None] = relationship()
lead: Mapped[Lead | None] = relationship()
class Product(Base, TimestampMixin):
__tablename__ = "crm_products"
__table_args__ = (
UniqueConstraint("tenant_id", "sku", name="uq_crm_products_tenant_sku"),
Index("ix_crm_products_tenant_name", "tenant_id", "name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sku: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
quantity: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
price: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class Quote(Base, TimestampMixin):
__tablename__ = "crm_quotes"
__table_args__ = (
UniqueConstraint("tenant_id", "number", name="uq_crm_quotes_tenant_number"),
Index("ix_crm_quotes_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
number: Mapped[str] = mapped_column(String(80), nullable=False)
subject: Mapped[str] = mapped_column(String(240), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="draft", nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False)
billing_address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
shipping_address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
discount_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
default=0,
nullable=False,
)
tax_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
adjustment_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2),
default=0,
nullable=False,
)
subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
grand_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
contact_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_contacts.id", ondelete="SET NULL"),
)
organization_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_organizations.id", ondelete="SET NULL"),
)
lead_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_leads.id", ondelete="SET NULL"),
)
owner_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
items: Mapped[list[QuoteItem]] = relationship(
back_populates="quote",
cascade="all, delete-orphan",
)
class QuoteItem(Base, TimestampMixin):
__tablename__ = "crm_quote_items"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
quote_id: Mapped[str] = mapped_column(
ForeignKey("crm_quotes.id", ondelete="CASCADE"),
nullable=False,
)
product_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_products.id", ondelete="SET NULL"),
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=1, nullable=False)
unit_price: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
tax_rate: Mapped[Decimal] = mapped_column(Numeric(6, 3), default=0, nullable=False)
line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False)
quote: Mapped[Quote] = relationship(back_populates="items")
class AuditEvent(Base):
__tablename__ = "crm_audit_events"
__table_args__ = (
Index("ix_crm_audit_tenant_entity", "tenant_id", "entity_type", "entity_id"),
Index("ix_crm_audit_tenant_created", "tenant_id", "created_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
actor_id: Mapped[str | None] = mapped_column(
ForeignKey("crm_users.id", ondelete="SET NULL"),
)
action: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str] = mapped_column(String(80), nullable=False)
entity_id: Mapped[str] = mapped_column(String(80), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
class IntegrationEvent(Base):
__tablename__ = "crm_integration_events"
__table_args__ = (
Index("ix_crm_integration_events_delivery", "status", "available_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
topic: Mapped[str] = mapped_column(String(140), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
status: Mapped[str] = mapped_column(String(24), default="pending", nullable=False)
attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
available_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)
class IntegrationCredential(Base, TimestampMixin):
__tablename__ = "crm_integration_credentials"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_crm_integration_key_name"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
key_prefix: Mapped[str] = mapped_column(String(20), nullable=False)
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ExternalLink(Base, TimestampMixin):
__tablename__ = "crm_external_links"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"provider",
"entity_type",
"external_id",
name="uq_crm_external_links_provider_entity",
),
Index("ix_crm_external_links_local", "tenant_id", "entity_type", "entity_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
provider: Mapped[str] = mapped_column(String(80), nullable=False)
entity_type: Mapped[str] = mapped_column(String(80), nullable=False)
entity_id: Mapped[str] = mapped_column(String(36), nullable=False)
external_id: Mapped[str] = mapped_column(String(255), nullable=False)
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class IdempotencyRecord(Base):
__tablename__ = "crm_idempotency_records"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"scope",
"idempotency_key",
name="uq_crm_idempotency_scope_key",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
tenant_id: Mapped[str] = mapped_column(
ForeignKey("crm_tenants.id", ondelete="CASCADE"),
nullable=False,
)
scope: Mapped[str] = mapped_column(String(100), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
response_code: Mapped[int] = mapped_column(Integer, nullable=False)
response_body: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=now_utc,
nullable=False,
)