Coverage for src / idx_api / routers / brokerages.py: 76%

224 statements  

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

1"""Brokerage endpoints for real estate firm management.""" 

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, RequiredUser 

11from idx_api.database import get_db 

12from idx_api.embeddings import ( 

13 build_brokerage_text, 

14 index_brokerage, 

15 search_brokerages, 

16) 

17from idx_api.models.brokerage import Brokerage 

18from idx_api.utils.cache import invalidate_brokerage_domains 

19 

20router = APIRouter() 

21 

22 

23# ===== Response Models ===== 

24 

25 

26class BrokerageResponse(BaseModel): 

27 """Brokerage response model.""" 

28 

29 id: int 

30 slug: str 

31 name: str 

32 tagline: str | None 

33 description: str | None 

34 # Address 

35 address_street: str | None 

36 address_city: str | None 

37 address_state: str | None 

38 address_state_abbr: str | None 

39 address_zip: str | None 

40 address_country: str | None 

41 # Map defaults 

42 map_center_lat: float | None 

43 map_center_lng: float | None 

44 map_zoom: int | None 

45 map_bounds_north: float | None 

46 map_bounds_south: float | None 

47 map_bounds_east: float | None 

48 map_bounds_west: float | None 

49 # Feature flags 

50 feature_semantic_search: bool 

51 feature_map_search: bool 

52 feature_favorites: bool 

53 feature_contact_form: bool 

54 feature_virtual_tours: bool 

55 feature_mortgage_calculator: bool 

56 # License 

57 license_type: str | None 

58 license_number: str | None 

59 # Branding 

60 logo_url: str | None 

61 primary_color: str | None 

62 # Social 

63 social_facebook: str | None 

64 social_twitter: str | None 

65 social_linkedin: str | None 

66 social_pinterest: str | None 

67 social_instagram: str | None 

68 social_youtube: str | None 

69 # Other 

70 franchise_affiliation: str | None 

71 va_loans: bool 

72 military_specialist: bool 

73 website: str | None 

74 stripe_customer_id: str | None 

75 # Hero section 

76 hero_headline: str | None 

77 hero_subtitle: str | None 

78 hero_search_examples: list[str] | None 

79 # Navigation 

80 nav_main: list[dict] | None 

81 nav_footer: list[dict] | None 

82 disabled_at: datetime | None 

83 created_at: datetime 

84 updated_at: datetime 

85 

86 class Config: 

87 from_attributes = True 

88 

89 

90class BrokerageCreate(BaseModel): 

91 """Brokerage creation request.""" 

92 

93 slug: str 

94 name: str 

95 tagline: str | None = None 

96 description: str | None = None 

97 # Address 

98 address_street: str | None = None 

99 address_city: str | None = None 

100 address_state: str | None = None 

101 address_state_abbr: str | None = None 

102 address_zip: str | None = None 

103 address_country: str | None = "US" 

104 # Map defaults 

105 map_center_lat: float | None = None 

106 map_center_lng: float | None = None 

107 map_zoom: int | None = None 

108 map_bounds_north: float | None = None 

109 map_bounds_south: float | None = None 

110 map_bounds_east: float | None = None 

111 map_bounds_west: float | None = None 

112 # Feature flags 

113 feature_semantic_search: bool = True 

114 feature_map_search: bool = True 

115 feature_favorites: bool = True 

116 feature_contact_form: bool = True 

117 feature_virtual_tours: bool = False 

118 feature_mortgage_calculator: bool = True 

119 # License 

120 license_type: str | None = None 

121 license_number: str | None = None 

122 # Branding 

123 logo_url: str | None = None 

124 primary_color: str | None = None 

125 # Social 

126 social_facebook: str | None = None 

127 social_twitter: str | None = None 

128 social_linkedin: str | None = None 

129 social_pinterest: str | None = None 

130 social_instagram: str | None = None 

131 social_youtube: str | None = None 

132 # Other 

133 franchise_affiliation: str | None = None 

134 va_loans: bool = False 

135 military_specialist: bool = False 

136 website: str | None = None 

137 # Hero section 

138 hero_headline: str | None = None 

139 hero_subtitle: str | None = None 

140 hero_search_examples: list[str] | None = None 

141 # Navigation 

142 nav_main: list[dict] | None = None 

143 nav_footer: list[dict] | None = None 

144 

145 

146class BrokerageUpdate(BaseModel): 

147 """Brokerage update request.""" 

148 

149 name: str | None = None 

150 tagline: str | None = None 

151 description: str | None = None 

152 # Address 

153 address_street: str | None = None 

154 address_city: str | None = None 

155 address_state: str | None = None 

156 address_state_abbr: str | None = None 

157 address_zip: str | None = None 

158 address_country: str | None = None 

159 # Map defaults 

160 map_center_lat: float | None = None 

161 map_center_lng: float | None = None 

162 map_zoom: int | None = None 

163 map_bounds_north: float | None = None 

164 map_bounds_south: float | None = None 

165 map_bounds_east: float | None = None 

166 map_bounds_west: float | None = None 

167 # Feature flags 

168 feature_semantic_search: bool | None = None 

169 feature_map_search: bool | None = None 

170 feature_favorites: bool | None = None 

171 feature_contact_form: bool | None = None 

172 feature_virtual_tours: bool | None = None 

173 feature_mortgage_calculator: bool | None = None 

174 # License 

175 license_type: str | None = None 

176 license_number: str | None = None 

177 # Branding 

178 logo_url: str | None = None 

179 primary_color: str | None = None 

180 # Social 

181 social_facebook: str | None = None 

182 social_twitter: str | None = None 

183 social_linkedin: str | None = None 

184 social_pinterest: str | None = None 

185 social_instagram: str | None = None 

186 social_youtube: str | None = None 

187 # Other 

188 franchise_affiliation: str | None = None 

189 va_loans: bool | None = None 

190 military_specialist: bool | None = None 

191 website: str | None = None 

192 # Hero section 

193 hero_headline: str | None = None 

194 hero_subtitle: str | None = None 

195 hero_search_examples: list[str] | None = None 

196 # Navigation 

197 nav_main: list[dict] | None = None 

198 nav_footer: list[dict] | None = None 

199 

200 

201class BrokerageSearchResult(BaseModel): 

202 """Brokerage search result with similarity score.""" 

203 

204 id: int 

205 slug: str 

206 name: str 

207 tagline: str | None 

208 military_specialist: bool 

209 va_loans: bool 

210 franchise_affiliation: str | None 

211 logo_url: str | None 

212 similarity: float 

213 

214 class Config: 

215 from_attributes = True 

216 

217 

218# ===== CRUD Endpoints ===== 

219 

220 

221@router.get("/brokerages") 

222async def list_brokerages( 

223 user: RequiredUser, 

224 db: Session = Depends(get_db), 

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

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

227): 

228 """ 

229 List all brokerages with pagination. 

230 

231 Accessible to all authenticated users. 

232 """ 

233 # Count total (excluding soft-deleted) 

234 total = db.scalar( 

235 select(func.count()).select_from(Brokerage).where(Brokerage.disabled_at.is_(None)) 

236 ) 

237 

238 # Get paginated results 

239 offset = (page - 1) * page_size 

240 brokerages = db.scalars( 

241 select(Brokerage) 

242 .where(Brokerage.disabled_at.is_(None)) 

243 .order_by(Brokerage.created_at.desc()) 

244 .offset(offset) 

245 .limit(page_size) 

246 ).all() 

247 

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

249 

250 return { 

251 "items": [BrokerageResponse.model_validate(b) for b in brokerages], 

252 "total": total, 

253 "page": page, 

254 "page_size": page_size, 

255 "total_pages": total_pages, 

256 } 

257 

258 

259@router.get("/brokerages/{brokerage_id}", response_model=BrokerageResponse) 

260async def get_brokerage( 

261 brokerage_id: int, 

262 user: RequiredUser, 

263 db: Session = Depends(get_db), 

264): 

265 """ 

266 Get a single brokerage by ID. 

267 

268 Accessible to all authenticated users. 

269 """ 

270 brokerage = db.get(Brokerage, brokerage_id) 

271 if not brokerage or brokerage.disabled_at: 

272 raise HTTPException(status_code=404, detail="Brokerage not found") 

273 

274 return BrokerageResponse.model_validate(brokerage) 

275 

276 

277@router.post("/brokerages", response_model=BrokerageResponse) 

278async def create_brokerage( 

279 data: BrokerageCreate, 

280 user: AdminUser, 

281 db: Session = Depends(get_db), 

282): 

283 """ 

284 Create a new brokerage. 

285 

286 Requires admin role. 

287 """ 

288 # Check for duplicate slug 

289 existing = db.scalar(select(Brokerage).where(Brokerage.slug == data.slug)) 

290 if existing: 

291 raise HTTPException(status_code=400, detail="Slug already exists") 

292 

293 brokerage = Brokerage( 

294 **data.model_dump(), 

295 created_at=datetime.utcnow(), 

296 updated_at=datetime.utcnow(), 

297 ) 

298 

299 db.add(brokerage) 

300 db.commit() 

301 db.refresh(brokerage) 

302 

303 # Index for vector search 

304 try: 

305 text_content = build_brokerage_text(brokerage) 

306 index_brokerage(db, brokerage.id, text_content) 

307 db.commit() 

308 except Exception as e: 

309 print(f"⚠️ Failed to index brokerage {brokerage.id}: {e}") 

310 

311 # Note: New brokerages won't have domains yet, but invalidate all caches 

312 # in case there's stale 404 responses cached 

313 await invalidate_brokerage_domains(brokerage.id, db) 

314 

315 return BrokerageResponse.model_validate(brokerage) 

316 

317 

318@router.put("/brokerages/{brokerage_id}", response_model=BrokerageResponse) 

319async def update_brokerage( 

320 brokerage_id: int, 

321 data: BrokerageUpdate, 

322 user: AdminUser, 

323 db: Session = Depends(get_db), 

324): 

325 """ 

326 Update an existing brokerage. 

327 

328 Requires admin role. 

329 """ 

330 brokerage = db.get(Brokerage, brokerage_id) 

331 if not brokerage or brokerage.disabled_at: 

332 raise HTTPException(status_code=404, detail="Brokerage not found") 

333 

334 # Update fields 

335 update_data = data.model_dump(exclude_unset=True) 

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

337 setattr(brokerage, field, value) 

338 

339 brokerage.updated_at = datetime.utcnow() 

340 

341 db.commit() 

342 db.refresh(brokerage) 

343 

344 # Re-index for vector search 

345 try: 

346 text_content = build_brokerage_text(brokerage) 

347 index_brokerage(db, brokerage.id, text_content) 

348 db.commit() 

349 except Exception as e: 

350 print(f"⚠️ Failed to index brokerage {brokerage.id}: {e}") 

351 

352 # Invalidate Astro frontend cache for this brokerage's domains 

353 await invalidate_brokerage_domains(brokerage.id, db) 

354 

355 return BrokerageResponse.model_validate(brokerage) 

356 

357 

358@router.delete("/brokerages/{brokerage_id}") 

359async def delete_brokerage( 

360 brokerage_id: int, 

361 user: AdminUser, 

362 db: Session = Depends(get_db), 

363): 

364 """ 

365 Soft-delete a brokerage. 

366 

367 Requires admin role. 

368 """ 

369 brokerage = db.get(Brokerage, brokerage_id) 

370 if not brokerage: 

371 raise HTTPException(status_code=404, detail="Brokerage not found") 

372 

373 # Invalidate cache before soft-delete (still have domains) 

374 await invalidate_brokerage_domains(brokerage.id, db) 

375 

376 brokerage.disabled_at = datetime.utcnow() 

377 brokerage.updated_at = datetime.utcnow() 

378 

379 db.commit() 

380 

381 return {"message": "Brokerage deleted successfully"} 

382 

383 

384# ===== Search Endpoint ===== 

385 

386 

387@router.get("/search/brokerages") 

388async def semantic_search_brokerages( 

389 user: RequiredUser, 

390 db: Session = Depends(get_db), 

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

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

393): 

394 """ 

395 Search brokerages using semantic similarity. 

396 

397 Examples: 

398 - "military relocation specialist" 

399 - "RE/MAX franchise" 

400 - "VA loan expert in Boise" 

401 

402 Uses embeddings to find semantically similar brokerages, not just keyword matches. 

403 """ 

404 try: 

405 results = search_brokerages(db, query=q, limit=limit) 

406 return { 

407 "query": q, 

408 "results": [BrokerageSearchResult.model_validate(r) for r in results], 

409 "total": len(results), 

410 } 

411 except Exception as e: 

412 raise HTTPException( 

413 status_code=503, 

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

415 )