Coverage for src / idx_api / routers / broker_contacts.py: 49%

136 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 11:09 -0700

1"""Broker contact endpoints for managing contact persons at brokerages.""" 

2 

3from datetime import datetime 

4 

5from fastapi import APIRouter, Depends, HTTPException, Query 

6from pydantic import BaseModel 

7from sqlalchemy import func, select 

8from sqlalchemy.orm import Session 

9 

10from idx_api.auth import AdminUser, BrokerUser, RequiredUser 

11from idx_api.database import get_db 

12from idx_api.embeddings import ( 

13 build_broker_contact_text, 

14 index_broker_contact, 

15 search_broker_contacts, 

16) 

17from idx_api.models.broker import Broker 

18 

19router = APIRouter() 

20 

21 

22# ===== Response Models ===== 

23 

24 

25class BrokerContactResponse(BaseModel): 

26 """Broker contact response model.""" 

27 

28 id: int 

29 brokerage_id: int 

30 name: str 

31 email: str 

32 phone: str | None 

33 phone_display: str | None 

34 license_type: str | None 

35 license_number: str | None 

36 is_primary: bool 

37 disabled_at: datetime | None 

38 created_at: datetime 

39 updated_at: datetime 

40 

41 class Config: 

42 from_attributes = True 

43 

44 

45class BrokerContactCreate(BaseModel): 

46 """Broker contact creation request.""" 

47 

48 brokerage_id: int 

49 name: str 

50 email: str 

51 phone: str | None = None 

52 phone_display: str | None = None 

53 license_type: str | None = None 

54 license_number: str | None = None 

55 is_primary: bool = False 

56 

57 

58class BrokerContactUpdate(BaseModel): 

59 """Broker contact update request.""" 

60 

61 brokerage_id: int | None = None 

62 name: str | None = None 

63 email: str | None = None 

64 phone: str | None = None 

65 phone_display: str | None = None 

66 license_type: str | None = None 

67 license_number: str | None = None 

68 is_primary: bool | None = None 

69 

70 

71class BrokerContactSearchResult(BaseModel): 

72 """Broker contact search result with similarity score.""" 

73 

74 id: int 

75 brokerage_id: int 

76 name: str 

77 email: str 

78 phone: str | None 

79 license_type: str | None 

80 is_primary: bool 

81 similarity: float 

82 

83 class Config: 

84 from_attributes = True 

85 

86 

87# ===== CRUD Endpoints ===== 

88 

89 

90@router.get("/broker-contacts") 

91async def list_broker_contacts( 

92 user: RequiredUser, 

93 db: Session = Depends(get_db), 

94 brokerage_id: int | None = Query(None, description="Filter by brokerage ID"), 

95 page: int = Query(1, ge=1), 

96 page_size: int = Query(20, ge=1, le=100), 

97): 

98 """ 

99 List broker contacts with pagination. 

100 

101 - Admins see all broker contacts 

102 - Brokers see only contacts in their brokerage 

103 - Can filter by brokerage_id 

104 """ 

105 # Build base query 

106 base_where = [Broker.disabled_at.is_(None)] 

107 

108 # Filter by brokerage 

109 if brokerage_id: 

110 base_where.append(Broker.brokerage_id == brokerage_id) 

111 elif user.role != "admin" and user.brokerage_id: 

112 # Non-admins can only see contacts from their own brokerage 

113 base_where.append(Broker.brokerage_id == user.brokerage_id) 

114 

115 # Count total (excluding soft-deleted) 

116 total = db.scalar( 

117 select(func.count()).select_from(Broker).where(*base_where) 

118 ) 

119 

120 # Get paginated results 

121 offset = (page - 1) * page_size 

122 contacts = db.scalars( 

123 select(Broker) 

124 .where(*base_where) 

125 .order_by(Broker.is_primary.desc(), Broker.created_at.desc()) 

126 .offset(offset) 

127 .limit(page_size) 

128 ).all() 

129 

130 total_pages = (total + page_size - 1) // page_size 

131 

132 return { 

133 "items": [BrokerContactResponse.model_validate(c) for c in contacts], 

134 "total": total, 

135 "page": page, 

136 "page_size": page_size, 

137 "total_pages": total_pages, 

138 } 

139 

140 

141@router.get("/broker-contacts/{contact_id}", response_model=BrokerContactResponse) 

142async def get_broker_contact( 

143 contact_id: int, 

144 user: RequiredUser, 

145 db: Session = Depends(get_db), 

146): 

147 """ 

148 Get a single broker contact by ID. 

149 

150 - Admins can access any contact 

151 - Non-admins can only access contacts from their brokerage 

152 """ 

153 contact = db.get(Broker, contact_id) 

154 if not contact or contact.disabled_at: 

155 raise HTTPException(status_code=404, detail="Broker contact not found") 

156 

157 # Authorization check for non-admins 

158 if user.role != "admin" and user.brokerage_id != contact.brokerage_id: 

159 raise HTTPException(status_code=403, detail="Access denied") 

160 

161 return BrokerContactResponse.model_validate(contact) 

162 

163 

164@router.post("/broker-contacts", response_model=BrokerContactResponse) 

165async def create_broker_contact( 

166 data: BrokerContactCreate, 

167 user: BrokerUser, # Broker or admin can create contacts 

168 db: Session = Depends(get_db), 

169): 

170 """ 

171 Create a new broker contact. 

172 

173 Requires broker or admin role. 

174 

175 If is_primary=True, will unset is_primary on other contacts for that brokerage. 

176 """ 

177 # Authorization: non-admins can only create contacts for their brokerage 

178 if user.role != "admin" and user.brokerage_id != data.brokerage_id: 

179 raise HTTPException(status_code=403, detail="Can only create contacts for your own brokerage") 

180 

181 # If setting as primary, unset primary for other contacts in this brokerage 

182 if data.is_primary: 

183 existing_primary = db.scalars( 

184 select(Broker).where( 

185 Broker.brokerage_id == data.brokerage_id, 

186 Broker.is_primary == True, 

187 Broker.disabled_at.is_(None) 

188 ) 

189 ).all() 

190 for contact in existing_primary: 

191 contact.is_primary = False 

192 contact.updated_at = datetime.utcnow() 

193 

194 contact = Broker( 

195 **data.model_dump(), 

196 created_at=datetime.utcnow(), 

197 updated_at=datetime.utcnow(), 

198 ) 

199 

200 db.add(contact) 

201 db.commit() 

202 db.refresh(contact) 

203 

204 # Index for vector search 

205 try: 

206 text_content = build_broker_contact_text(contact) 

207 index_broker_contact(db, contact.id, text_content) 

208 db.commit() 

209 except Exception as e: 

210 print(f"⚠️ Failed to index broker contact {contact.id}: {e}") 

211 

212 return BrokerContactResponse.model_validate(contact) 

213 

214 

215@router.put("/broker-contacts/{contact_id}", response_model=BrokerContactResponse) 

216async def update_broker_contact( 

217 contact_id: int, 

218 data: BrokerContactUpdate, 

219 user: BrokerUser, 

220 db: Session = Depends(get_db), 

221): 

222 """ 

223 Update an existing broker contact. 

224 

225 Requires broker or admin role. 

226 

227 If setting is_primary=True, will unset is_primary on other contacts for that brokerage. 

228 """ 

229 contact = db.get(Broker, contact_id) 

230 if not contact or contact.disabled_at: 

231 raise HTTPException(status_code=404, detail="Broker contact not found") 

232 

233 # Authorization: non-admins can only edit contacts from their brokerage 

234 if user.role != "admin" and user.brokerage_id != contact.brokerage_id: 

235 raise HTTPException(status_code=403, detail="Access denied") 

236 

237 # Update fields 

238 update_data = data.model_dump(exclude_unset=True) 

239 

240 # If setting as primary, unset primary for other contacts in this brokerage 

241 if update_data.get("is_primary"): 

242 brokerage_id = update_data.get("brokerage_id", contact.brokerage_id) 

243 existing_primary = db.scalars( 

244 select(Broker).where( 

245 Broker.brokerage_id == brokerage_id, 

246 Broker.is_primary == True, 

247 Broker.id != contact_id, 

248 Broker.disabled_at.is_(None) 

249 ) 

250 ).all() 

251 for other_contact in existing_primary: 

252 other_contact.is_primary = False 

253 other_contact.updated_at = datetime.utcnow() 

254 

255 for field, value in update_data.items(): 

256 setattr(contact, field, value) 

257 

258 contact.updated_at = datetime.utcnow() 

259 

260 db.commit() 

261 db.refresh(contact) 

262 

263 # Re-index for vector search 

264 try: 

265 text_content = build_broker_contact_text(contact) 

266 index_broker_contact(db, contact.id, text_content) 

267 db.commit() 

268 except Exception as e: 

269 print(f"⚠️ Failed to index broker contact {contact.id}: {e}") 

270 

271 return BrokerContactResponse.model_validate(contact) 

272 

273 

274@router.delete("/broker-contacts/{contact_id}") 

275async def delete_broker_contact( 

276 contact_id: int, 

277 user: AdminUser, # Only admins can delete contacts 

278 db: Session = Depends(get_db), 

279): 

280 """ 

281 Soft-delete a broker contact. 

282 

283 Requires admin role. 

284 """ 

285 contact = db.get(Broker, contact_id) 

286 if not contact: 

287 raise HTTPException(status_code=404, detail="Broker contact not found") 

288 

289 contact.disabled_at = datetime.utcnow() 

290 contact.updated_at = datetime.utcnow() 

291 

292 db.commit() 

293 

294 return {"message": "Broker contact deleted successfully"} 

295 

296 

297# ===== Search Endpoint ===== 

298 

299 

300@router.get("/search/broker-contacts") 

301async def semantic_search_broker_contacts( 

302 user: RequiredUser, 

303 db: Session = Depends(get_db), 

304 q: str = Query(..., min_length=2, description="Search query"), 

305 brokerage_id: int | None = Query(None, description="Filter by brokerage ID"), 

306 limit: int = Query(10, ge=1, le=50, description="Maximum results"), 

307): 

308 """ 

309 Search broker contacts using semantic similarity. 

310 

311 Examples: 

312 - "military relocation expert" 

313 - "licensed real estate broker" 

314 - "primary contact for RE/MAX" 

315 

316 Uses embeddings to find semantically similar broker contacts, not just keyword matches. 

317 """ 

318 try: 

319 results = search_broker_contacts( 

320 db, 

321 query=q, 

322 brokerage_id=brokerage_id, 

323 limit=limit 

324 ) 

325 return { 

326 "query": q, 

327 "results": [BrokerContactSearchResult.model_validate(r) for r in results], 

328 "total": len(results), 

329 } 

330 except Exception as e: 

331 raise HTTPException( 

332 status_code=503, 

333 detail=f"Search service unavailable: {str(e)}", 

334 )