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

1"""Client CRM models - Client entity, contacts, and activity log.""" 

2 

3from datetime import datetime 

4from typing import TYPE_CHECKING, Optional 

5 

6from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text 

7from sqlalchemy.orm import Mapped, mapped_column, relationship 

8 

9from idx_api.models.base import Base, TimestampMixin 

10 

11if TYPE_CHECKING: 

12 from idx_api.models.agent import Agent 

13 from idx_api.models.brokerage import Brokerage 

14 

15 

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] 

26 

27 

28class Client(Base, TimestampMixin): 

29 """CRM Client entity - can be person, couple, trust, or business.""" 

30 

31 __tablename__ = "clients" 

32 

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 ) 

40 

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 

47 

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)) 

54 

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 

62 

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)) 

67 

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) 

73 

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)) 

77 

78 # Soft delete 

79 disabled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) 

80 

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 ) 

92 

93 def __repr__(self) -> str: 

94 return f"<Client(id={self.id}, display_name='{self.display_name}', type='{self.client_type}')>" 

95 

96 

97class ClientContact(Base, TimestampMixin): 

98 """Contact person associated with a client entity.""" 

99 

100 __tablename__ = "client_contacts" 

101 

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 ) 

106 

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)) 

113 

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 ) 

121 

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)) 

128 

129 # Soft delete 

130 disabled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) 

131 

132 # Relationships 

133 client: Mapped["Client"] = relationship(back_populates="contacts") 

134 

135 def __repr__(self) -> str: 

136 return f"<ClientContact(id={self.id}, name='{self.first_name} {self.last_name}', role='{self.role}')>" 

137 

138 

139class ClientActivity(Base, TimestampMixin): 

140 """Activity log entry for client interactions.""" 

141 

142 __tablename__ = "client_activities" 

143 

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 

152 

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 ) 

162 

163 # Metadata 

164 outcome: Mapped[Optional[str]] = mapped_column(String(200)) 

165 follow_up_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) 

166 

167 # Relationships 

168 client: Mapped["Client"] = relationship(back_populates="activities") 

169 agent: Mapped[Optional["Agent"]] = relationship(foreign_keys=[agent_id]) 

170 

171 def __repr__(self) -> str: 

172 return f"<ClientActivity(id={self.id}, type='{self.activity_type}', title='{self.title}')>"