Coverage for src / idx_api / routers / public.py: 75%

214 statements  

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

1"""Public API endpoints for website pages (no auth required).""" 

2 

3from fastapi import APIRouter, Depends, Request 

4from pydantic import BaseModel 

5from sqlalchemy import select 

6from sqlalchemy.orm import Session, joinedload 

7 

8from idx_api.database import get_db 

9from idx_api.models.agent import Agent 

10from idx_api.models.broker import Broker 

11from idx_api.models.brokerage import Brokerage 

12from idx_api.models.brokerage_domain import BrokerageDomain 

13from idx_api.models.brokerage_service_area import BrokerageServiceArea 

14from idx_api.utils.colors import generate_palette 

15 

16router = APIRouter() 

17 

18 

19# ===== SiteConfig Response Models ===== 

20# These mirror the TypeScript SiteConfig interface in idx-web/src/config/index.ts 

21 

22 

23class SiteSection(BaseModel): 

24 name: str 

25 slug: str # Brokerage slug for multi-tenant filtering (e.g., "elevate-idaho") 

26 tagline: str 

27 description: str | None = None 

28 url: str | None = None 

29 locale: str = "en-US" 

30 timezone: str | None = None 

31 

32 

33class LogoConfig(BaseModel): 

34 url: str 

35 alt: str 

36 width: int | None = None 

37 height: int | None = None 

38 

39 

40class ColorConfig(BaseModel): 

41 primary: dict[str, str] # Full 11-shade palette generated from base color 

42 accent: str 

43 background: str 

44 foreground: str 

45 muted: str 

46 

47 

48class FontConfig(BaseModel): 

49 heading: str 

50 body: str 

51 

52 

53class BrandingSection(BaseModel): 

54 logo: LogoConfig 

55 favicon: str 

56 colors: ColorConfig 

57 fonts: FontConfig 

58 

59 

60class AddressConfig(BaseModel): 

61 street: str 

62 city: str 

63 state: str 

64 stateAbbr: str 

65 zip: str 

66 country: str 

67 

68 

69class LicenseConfig(BaseModel): 

70 type: str 

71 number: str 

72 

73 

74class ContactSection(BaseModel): 

75 phone: str 

76 phoneDisplay: str 

77 email: str | None = None 

78 address: AddressConfig 

79 license: LicenseConfig | None = None 

80 

81 

82class AreaConfig(BaseModel): 

83 name: str 

84 slug: str 

85 

86 

87class SocialSection(BaseModel): 

88 facebook: str | None = None 

89 twitter: str | None = None 

90 linkedin: str | None = None 

91 pinterest: str | None = None 

92 instagram: str | None = None 

93 youtube: str | None = None 

94 

95 

96class NavItem(BaseModel): 

97 label: str 

98 href: str 

99 disabled: bool | None = None 

100 

101 

102class NavigationSection(BaseModel): 

103 main: list[NavItem] 

104 footer: list[NavItem] 

105 

106 

107class FeaturesSection(BaseModel): 

108 semanticSearch: bool = True 

109 mapSearch: bool = True 

110 favorites: bool = True 

111 contactForm: bool = True 

112 virtualTours: bool = False 

113 mortgageCalculator: bool = True 

114 

115 

116class HeroSection(BaseModel): 

117 headline: str 

118 subtitle: str 

119 searchExamples: list[str] 

120 

121 

122class OpenGraphConfig(BaseModel): 

123 type: str = "website" 

124 siteName: str 

125 

126 

127class SeoSection(BaseModel): 

128 titleTemplate: str 

129 defaultTitle: str 

130 defaultDescription: str 

131 keywords: list[str] 

132 openGraph: OpenGraphConfig 

133 

134 

135class LegalSection(BaseModel): 

136 copyright: str 

137 copyrightYear: int 

138 disclaimer: str 

139 mlsDisclaimer: str 

140 

141 

142class MapCenter(BaseModel): 

143 lat: float 

144 lng: float 

145 

146 

147class MapBounds(BaseModel): 

148 north: float 

149 south: float 

150 east: float 

151 west: float 

152 

153 

154class MapDefaultsSection(BaseModel): 

155 center: MapCenter 

156 zoom: int 

157 bounds: MapBounds | None = None 

158 

159 

160class ApiSection(BaseModel): 

161 baseUrl: str 

162 publicUrl: str 

163 

164 

165class SiteConfigResponse(BaseModel): 

166 """Complete site configuration for white-label IDX websites.""" 

167 

168 site: SiteSection 

169 branding: BrandingSection 

170 contact: ContactSection 

171 serviceAreas: list[AreaConfig] 

172 featuredAreas: list[AreaConfig] 

173 social: SocialSection 

174 navigation: NavigationSection 

175 features: FeaturesSection 

176 hero: HeroSection 

177 seo: SeoSection 

178 legal: LegalSection 

179 mapDefaults: MapDefaultsSection 

180 api: ApiSection 

181 

182 

183# ===== Other Response Models ===== 

184 

185 

186class PublicBrokerageResponse(BaseModel): 

187 """Public brokerage information (minimal).""" 

188 

189 id: int 

190 name: str 

191 tagline: str | None 

192 logo_url: str | None 

193 website: str | None 

194 

195 class Config: 

196 from_attributes = True 

197 

198 

199class PublicAgentResponse(BaseModel): 

200 """Public agent information for about page.""" 

201 

202 id: int 

203 name: str 

204 slug: str 

205 email: str 

206 phone: str | None 

207 bio: str | None 

208 photo_url: str | None 

209 profile_url: str | None 

210 mls_id: str | None 

211 brokerage_id: int 

212 status: str 

213 

214 class Config: 

215 from_attributes = True 

216 

217 

218# ===== Public Endpoints ===== 

219 

220 

221@router.get("/public/brokerages", response_model=list[PublicBrokerageResponse]) 

222async def get_public_brokerages(db: Session = Depends(get_db)): 

223 """ 

224 Get all active brokerages (public - no auth required). 

225 

226 Used by public pages like about page. 

227 """ 

228 brokerages = db.scalars( 

229 select(Brokerage) 

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

231 .order_by(Brokerage.name) 

232 ).all() 

233 

234 return [PublicBrokerageResponse.model_validate(b) for b in brokerages] 

235 

236 

237@router.get("/public/agents", response_model=list[PublicAgentResponse]) 

238async def get_public_agents( 

239 brokerage_slug: str | None = None, 

240 db: Session = Depends(get_db), 

241): 

242 """ 

243 Get active agents (public - no auth required). 

244 

245 Used by public pages like about page and agent listings. 

246 

247 Args: 

248 brokerage_slug: Optional filter to get agents for a specific brokerage. 

249 If not provided, returns all active agents. 

250 """ 

251 query = select(Agent).where( 

252 Agent.status == "active", 

253 Agent.disabled_at.is_(None), 

254 ) 

255 

256 # Filter by brokerage if slug provided 

257 if brokerage_slug: 

258 query = query.join(Brokerage).where(Brokerage.slug == brokerage_slug) 

259 

260 agents = db.scalars(query.order_by(Agent.name)).all() 

261 

262 return [PublicAgentResponse.model_validate(a) for a in agents] 

263 

264 

265@router.get("/public/agents/{slug}", response_model=PublicAgentResponse) 

266async def get_public_agent_by_slug(slug: str, db: Session = Depends(get_db)): 

267 """ 

268 Get agent by slug (public - no auth required). 

269 

270 Used by public agent detail pages. 

271 """ 

272 agent = db.scalar( 

273 select(Agent) 

274 .where( 

275 Agent.slug == slug, 

276 Agent.status == "active", 

277 Agent.disabled_at.is_(None), 

278 ) 

279 ) 

280 

281 if not agent: 

282 from fastapi import HTTPException 

283 raise HTTPException(status_code=404, detail="Agent not found") 

284 

285 return PublicAgentResponse.model_validate(agent) 

286 

287 

288# ===== Site Config Endpoint ===== 

289 

290 

291def _slugify(text: str) -> str: 

292 """Convert text to URL-friendly slug.""" 

293 import re 

294 slug = text.lower() 

295 slug = re.sub(r'[^a-z0-9\s-]', '', slug) 

296 slug = re.sub(r'[\s_]+', '-', slug) 

297 slug = re.sub(r'-+', '-', slug) 

298 return slug.strip('-') 

299 

300 

301# Default navigation items (used when brokerage has no custom nav) 

302DEFAULT_NAV_MAIN = [ 

303 NavItem(label="Search", href="/search"), 

304 NavItem(label="Map", href="/map"), 

305 NavItem(label="About", href="/about"), 

306 NavItem(label="Blog", href="/blog"), 

307] 

308 

309DEFAULT_NAV_FOOTER = [ 

310 NavItem(label="About", href="/about"), 

311 NavItem(label="Contact", href="/contact"), 

312 NavItem(label="Privacy", href="/privacy"), 

313 NavItem(label="Terms", href="/terms"), 

314 NavItem(label="Accessibility", href="/accessibility"), 

315 NavItem(label="Fight Spam", href="/fight-spam"), 

316] 

317 

318 

319def _build_navigation(brokerage) -> NavigationSection: 

320 """Build navigation from database with fallback defaults.""" 

321 # Convert DB nav items to NavItem objects, or use defaults 

322 if brokerage.nav_main: 

323 main_items = [NavItem(**item) for item in brokerage.nav_main] 

324 else: 

325 main_items = DEFAULT_NAV_MAIN 

326 

327 if brokerage.nav_footer: 

328 footer_items = [NavItem(**item) for item in brokerage.nav_footer] 

329 else: 

330 footer_items = DEFAULT_NAV_FOOTER 

331 

332 return NavigationSection(main=main_items, footer=footer_items) 

333 

334 

335def _build_site_config( 

336 brokerage: Brokerage, 

337 primary_contact: Broker | None, 

338 domain: str, 

339 request: Request, 

340) -> SiteConfigResponse: 

341 """Build SiteConfig from brokerage and contact data.""" 

342 from datetime import datetime 

343 

344 # Determine site URL 

345 site_url = brokerage.website or f"https://{domain}" 

346 

347 # Use primary color or default orange 

348 primary_color = brokerage.primary_color or "#ee7711" 

349 accent_color = primary_color 

350 

351 # Contact info from primary broker or defaults 

352 phone = primary_contact.phone if primary_contact and primary_contact.phone else "+1 000-000-0000" 

353 phone_display = primary_contact.phone_display if primary_contact and primary_contact.phone_display else "(000) 000-0000" 

354 email = primary_contact.email if primary_contact else None 

355 

356 # License info from brokerage 

357 license_config = None 

358 if brokerage.license_type and brokerage.license_number: 

359 license_config = LicenseConfig( 

360 type=brokerage.license_type, 

361 number=brokerage.license_number 

362 ) 

363 

364 # Build API base URL from request 

365 api_base_url = str(request.base_url).rstrip("/") 

366 

367 return SiteConfigResponse( 

368 site=SiteSection( 

369 name=brokerage.name, 

370 slug=brokerage.slug, # Used for multi-tenant blog content filtering 

371 tagline=brokerage.tagline or "Real Estate Excellence", 

372 description=brokerage.description or f"Your trusted partner for real estate.", 

373 url=site_url, 

374 locale="en-US", 

375 timezone="America/Boise", 

376 ), 

377 branding=BrandingSection( 

378 logo=LogoConfig( 

379 url=brokerage.logo_url or "/images/logo.png", 

380 alt=f"{brokerage.name} Logo", 

381 width=400, 

382 height=86, 

383 ), 

384 favicon="/favicon.svg", 

385 colors=ColorConfig( 

386 primary=generate_palette(primary_color), # Full 11-shade palette 

387 accent=accent_color, 

388 background="#0f172a", 

389 foreground="#f8fafc", 

390 muted="#64748b", 

391 ), 

392 fonts=FontConfig(heading="Inter", body="Inter"), 

393 ), 

394 contact=ContactSection( 

395 phone=phone, 

396 phoneDisplay=phone_display, 

397 email=email, 

398 address=AddressConfig( 

399 street=brokerage.address_street or "", 

400 city=brokerage.address_city or "Twin Falls", 

401 state=brokerage.address_state or "Idaho", 

402 stateAbbr=brokerage.address_state_abbr or "ID", 

403 zip=brokerage.address_zip or "83301", 

404 country=brokerage.address_country or "US", 

405 ), 

406 license=license_config, 

407 ), 

408 serviceAreas=[ 

409 AreaConfig(name=area.name, slug=area.slug) 

410 for area in brokerage.service_areas 

411 if not area.is_featured 

412 ], 

413 featuredAreas=[ 

414 AreaConfig(name=area.name, slug=area.slug) 

415 for area in brokerage.service_areas 

416 if area.is_featured 

417 ], 

418 social=SocialSection( 

419 facebook=brokerage.social_facebook, 

420 twitter=brokerage.social_twitter, 

421 linkedin=brokerage.social_linkedin, 

422 pinterest=brokerage.social_pinterest, 

423 instagram=brokerage.social_instagram, 

424 youtube=brokerage.social_youtube, 

425 ), 

426 navigation=_build_navigation(brokerage), 

427 features=FeaturesSection( 

428 semanticSearch=brokerage.feature_semantic_search, 

429 mapSearch=brokerage.feature_map_search, 

430 favorites=brokerage.feature_favorites, 

431 contactForm=brokerage.feature_contact_form, 

432 virtualTours=brokerage.feature_virtual_tours, 

433 mortgageCalculator=brokerage.feature_mortgage_calculator, 

434 ), 

435 hero=HeroSection( 

436 headline=brokerage.hero_headline or f"Find Your Perfect Home with {brokerage.name}", 

437 subtitle=brokerage.hero_subtitle or "Search thousands of properties with natural language or advanced filters", 

438 searchExamples=brokerage.hero_search_examples or [ 

439 "modern kitchen with granite countertops", 

440 "family home near downtown", 

441 "cozy cottage with large backyard", 

442 "open floor plan with natural light", 

443 ], 

444 ), 

445 seo=SeoSection( 

446 titleTemplate=f"%s | {brokerage.name}", 

447 defaultTitle=f"{brokerage.name} - Idaho Real Estate", 

448 defaultDescription=brokerage.tagline or f"Find your dream home with {brokerage.name}.", 

449 keywords=[ 

450 "Idaho real estate", 

451 "homes for sale", 

452 brokerage.name, 

453 ], 

454 openGraph=OpenGraphConfig(siteName=brokerage.name), 

455 ), 

456 legal=LegalSection( 

457 copyright=brokerage.name, 

458 copyrightYear=datetime.now().year, 

459 disclaimer="The information provided is for general informational purposes only and does not constitute professional advice.", 

460 mlsDisclaimer="IDX information provided by MLSGrid. All data is obtained from various sources and may not have been verified.", 

461 ), 

462 mapDefaults=MapDefaultsSection( 

463 center=MapCenter( 

464 lat=brokerage.map_center_lat or 42.5558, 

465 lng=brokerage.map_center_lng or -114.4609, 

466 ), 

467 zoom=brokerage.map_zoom or 10, 

468 bounds=MapBounds( 

469 north=brokerage.map_bounds_north or 43.5, 

470 south=brokerage.map_bounds_south or 42.0, 

471 east=brokerage.map_bounds_east or -113.5, 

472 west=brokerage.map_bounds_west or -115.5, 

473 ) if brokerage.map_bounds_north else None, 

474 ), 

475 api=ApiSection( 

476 baseUrl=api_base_url, 

477 publicUrl="/api", 

478 ), 

479 ) 

480 

481 

482@router.get("/public/site-config", response_model=SiteConfigResponse, response_model_exclude_none=True) 

483async def get_site_config(request: Request, db: Session = Depends(get_db)): 

484 """ 

485 Get site configuration based on request domain (Host header). 

486 

487 This endpoint enables white-label multi-tenant support by returning 

488 brokerage-specific configuration based on which domain is being accessed. 

489 

490 The domain must be: 

491 1. Registered in the brokerage_domains table 

492 2. Associated with an active (non-disabled) brokerage 

493 

494 If the domain has a verified primary contact (broker), their contact 

495 info will be used. Otherwise, reasonable defaults are provided. 

496 """ 

497 from fastapi import HTTPException 

498 

499 # Get host from request header - prefer X-Forwarded-Host (set by reverse proxy) 

500 # Fall back to Host header for direct access 

501 host = request.headers.get("x-forwarded-host", "").lower() 

502 if not host: 

503 host = request.headers.get("host", "").lower() 

504 # Strip port if present 

505 if ":" in host: 

506 host = host.split(":")[0] 

507 

508 if not host: 

509 raise HTTPException(status_code=400, detail="Missing Host header") 

510 

511 # Look up domain in database (prefer verified, but also allow unverified for development) 

512 # Eagerly load brokerage and its service_areas relationship 

513 domain_record = db.scalar( 

514 select(BrokerageDomain) 

515 .options( 

516 joinedload(BrokerageDomain.brokerage) 

517 .joinedload(Brokerage.service_areas) 

518 ) 

519 .where( 

520 BrokerageDomain.domain == host, 

521 ) 

522 .order_by(BrokerageDomain.is_verified.desc()) # Prefer verified 

523 ) 

524 

525 if not domain_record: 

526 raise HTTPException( 

527 status_code=404, 

528 detail=f"Domain '{host}' is not configured. Please add it in the admin panel." 

529 ) 

530 

531 brokerage = domain_record.brokerage 

532 

533 if not brokerage or brokerage.disabled_at is not None: 

534 raise HTTPException( 

535 status_code=404, 

536 detail=f"Brokerage for domain '{host}' is not active." 

537 ) 

538 

539 # Get primary broker contact for this brokerage 

540 primary_contact = db.scalar( 

541 select(Broker) 

542 .where( 

543 Broker.brokerage_id == brokerage.id, 

544 Broker.is_primary == True, 

545 Broker.disabled_at.is_(None), 

546 ) 

547 ) 

548 

549 # Build and return the site config 

550 return _build_site_config(brokerage, primary_contact, host, request)