Coverage for src / idx_api / models / client.py: 100%
74 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
1"""Client CRM models - Client entity, contacts, and activity log."""
3from datetime import datetime
4from typing import TYPE_CHECKING, Optional
6from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
7from sqlalchemy.orm import Mapped, mapped_column, relationship
9from idx_api.models.base import Base, TimestampMixin
11if TYPE_CHECKING:
12 from idx_api.models.agent import Agent
13 from idx_api.models.brokerage import Brokerage
16# Enum values as constants for validation
17CLIENT_TYPES = ["person", "couple", "trust", "business"]
18INTEREST_TYPES = ["buyer", "seller", "both"]
19BUYER_STATUSES = ["lead", "searching", "touring", "offer_pending", "under_contract", "closed"]
20SELLER_STATUSES = ["lead", "preparing", "listed", "offer_received", "under_contract", "closed"]
21CONTACT_ROLES = ["primary", "spouse", "partner", "trustee", "representative", "other"]
22ACTIVITY_TYPES = [
23 "note", "call", "email", "text", "showing", "open_house",
24 "offer", "contract", "closing", "other"
25]
28class Client(Base, TimestampMixin):
29 """CRM Client entity - can be person, couple, trust, or business."""
31 __tablename__ = "clients"
33 id: Mapped[int] = mapped_column(primary_key=True)
34 brokerage_id: Mapped[int] = mapped_column(
35 Integer, ForeignKey("brokerages.id", ondelete="CASCADE"), nullable=False, index=True
36 )
37 assigned_agent_id: Mapped[Optional[int]] = mapped_column(
38 Integer, ForeignKey("agents.id", ondelete="SET NULL"), index=True
39 )
41 # Identity
42 client_type: Mapped[str] = mapped_column(
43 String(20), nullable=False, default="person", server_default="person"
44 )
45 display_name: Mapped[str] = mapped_column(String(200), nullable=False)
46 entity_name: Mapped[Optional[str]] = mapped_column(String(200)) # Trust/business name
48 # CRM Status
49 interest_type: Mapped[str] = mapped_column(
50 String(20), nullable=False, default="buyer", server_default="buyer"
51 )
52 buyer_status: Mapped[Optional[str]] = mapped_column(String(30))
53 seller_status: Mapped[Optional[str]] = mapped_column(String(30))
55 # Buyer Preferences
56 budget_min: Mapped[Optional[int]] = mapped_column(Integer)
57 budget_max: Mapped[Optional[int]] = mapped_column(Integer)
58 beds_min: Mapped[Optional[int]] = mapped_column(Integer)
59 baths_min: Mapped[Optional[float]] = mapped_column(Float)
60 property_types: Mapped[Optional[str]] = mapped_column(Text) # JSON array
61 preferred_areas: Mapped[Optional[str]] = mapped_column(Text) # JSON array
63 # Seller Preferences
64 listing_address: Mapped[Optional[str]] = mapped_column(String(500))
65 expected_price: Mapped[Optional[int]] = mapped_column(Integer)
66 listing_timeline: Mapped[Optional[str]] = mapped_column(String(100))
68 # Profile
69 source: Mapped[Optional[str]] = mapped_column(String(100))
70 referral_name: Mapped[Optional[str]] = mapped_column(String(200))
71 tags: Mapped[Optional[str]] = mapped_column(Text) # JSON array
72 notes: Mapped[Optional[str]] = mapped_column(Text)
74 # Contact tracking
75 first_contact_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
76 last_contact_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
78 # Soft delete
79 disabled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
81 # Relationships
82 brokerage: Mapped["Brokerage"] = relationship(back_populates="clients")
83 assigned_agent: Mapped[Optional["Agent"]] = relationship(
84 back_populates="clients", foreign_keys=[assigned_agent_id]
85 )
86 contacts: Mapped[list["ClientContact"]] = relationship(
87 back_populates="client", cascade="all, delete-orphan"
88 )
89 activities: Mapped[list["ClientActivity"]] = relationship(
90 back_populates="client", cascade="all, delete-orphan"
91 )
93 def __repr__(self) -> str:
94 return f"<Client(id={self.id}, display_name='{self.display_name}', type='{self.client_type}')>"
97class ClientContact(Base, TimestampMixin):
98 """Contact person associated with a client entity."""
100 __tablename__ = "client_contacts"
102 id: Mapped[int] = mapped_column(primary_key=True)
103 client_id: Mapped[int] = mapped_column(
104 Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True
105 )
107 # Person Info
108 first_name: Mapped[str] = mapped_column(String(100), nullable=False)
109 last_name: Mapped[str] = mapped_column(String(100), nullable=False)
110 email: Mapped[Optional[str]] = mapped_column(String(255), index=True)
111 phone: Mapped[Optional[str]] = mapped_column(String(20))
112 phone_display: Mapped[Optional[str]] = mapped_column(String(20))
114 # Role
115 role: Mapped[str] = mapped_column(
116 String(30), nullable=False, default="primary", server_default="primary"
117 )
118 is_primary: Mapped[bool] = mapped_column(
119 Boolean, default=True, server_default="1", nullable=False
120 )
122 # Address
123 address_line1: Mapped[Optional[str]] = mapped_column(String(200))
124 address_line2: Mapped[Optional[str]] = mapped_column(String(200))
125 city: Mapped[Optional[str]] = mapped_column(String(100))
126 state: Mapped[Optional[str]] = mapped_column(String(50))
127 zip_code: Mapped[Optional[str]] = mapped_column(String(20))
129 # Soft delete
130 disabled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
132 # Relationships
133 client: Mapped["Client"] = relationship(back_populates="contacts")
135 def __repr__(self) -> str:
136 return f"<ClientContact(id={self.id}, name='{self.first_name} {self.last_name}', role='{self.role}')>"
139class ClientActivity(Base, TimestampMixin):
140 """Activity log entry for client interactions."""
142 __tablename__ = "client_activities"
144 id: Mapped[int] = mapped_column(primary_key=True)
145 client_id: Mapped[int] = mapped_column(
146 Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True
147 )
148 agent_id: Mapped[Optional[int]] = mapped_column(
149 Integer, ForeignKey("agents.id", ondelete="SET NULL"), index=True
150 )
151 property_id: Mapped[Optional[str]] = mapped_column(String(50), index=True) # MLS ListingKey
153 # Activity
154 activity_type: Mapped[str] = mapped_column(
155 String(30), nullable=False, default="note", server_default="note"
156 )
157 title: Mapped[str] = mapped_column(String(200), nullable=False)
158 description: Mapped[Optional[str]] = mapped_column(Text)
159 activity_at: Mapped[datetime] = mapped_column(
160 DateTime(timezone=True), nullable=False
161 )
163 # Metadata
164 outcome: Mapped[Optional[str]] = mapped_column(String(200))
165 follow_up_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
167 # Relationships
168 client: Mapped["Client"] = relationship(back_populates="activities")
169 agent: Mapped[Optional["Agent"]] = relationship(foreign_keys=[agent_id])
171 def __repr__(self) -> str:
172 return f"<ClientActivity(id={self.id}, type='{self.activity_type}', title='{self.title}')>"