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
« 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)."""
3from fastapi import APIRouter, Depends, Request
4from pydantic import BaseModel
5from sqlalchemy import select
6from sqlalchemy.orm import Session, joinedload
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
16router = APIRouter()
19# ===== SiteConfig Response Models =====
20# These mirror the TypeScript SiteConfig interface in idx-web/src/config/index.ts
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
33class LogoConfig(BaseModel):
34 url: str
35 alt: str
36 width: int | None = None
37 height: int | None = None
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
48class FontConfig(BaseModel):
49 heading: str
50 body: str
53class BrandingSection(BaseModel):
54 logo: LogoConfig
55 favicon: str
56 colors: ColorConfig
57 fonts: FontConfig
60class AddressConfig(BaseModel):
61 street: str
62 city: str
63 state: str
64 stateAbbr: str
65 zip: str
66 country: str
69class LicenseConfig(BaseModel):
70 type: str
71 number: str
74class ContactSection(BaseModel):
75 phone: str
76 phoneDisplay: str
77 email: str | None = None
78 address: AddressConfig
79 license: LicenseConfig | None = None
82class AreaConfig(BaseModel):
83 name: str
84 slug: str
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
96class NavItem(BaseModel):
97 label: str
98 href: str
99 disabled: bool | None = None
102class NavigationSection(BaseModel):
103 main: list[NavItem]
104 footer: list[NavItem]
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
116class HeroSection(BaseModel):
117 headline: str
118 subtitle: str
119 searchExamples: list[str]
122class OpenGraphConfig(BaseModel):
123 type: str = "website"
124 siteName: str
127class SeoSection(BaseModel):
128 titleTemplate: str
129 defaultTitle: str
130 defaultDescription: str
131 keywords: list[str]
132 openGraph: OpenGraphConfig
135class LegalSection(BaseModel):
136 copyright: str
137 copyrightYear: int
138 disclaimer: str
139 mlsDisclaimer: str
142class MapCenter(BaseModel):
143 lat: float
144 lng: float
147class MapBounds(BaseModel):
148 north: float
149 south: float
150 east: float
151 west: float
154class MapDefaultsSection(BaseModel):
155 center: MapCenter
156 zoom: int
157 bounds: MapBounds | None = None
160class ApiSection(BaseModel):
161 baseUrl: str
162 publicUrl: str
165class SiteConfigResponse(BaseModel):
166 """Complete site configuration for white-label IDX websites."""
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
183# ===== Other Response Models =====
186class PublicBrokerageResponse(BaseModel):
187 """Public brokerage information (minimal)."""
189 id: int
190 name: str
191 tagline: str | None
192 logo_url: str | None
193 website: str | None
195 class Config:
196 from_attributes = True
199class PublicAgentResponse(BaseModel):
200 """Public agent information for about page."""
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
214 class Config:
215 from_attributes = True
218# ===== Public Endpoints =====
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).
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()
234 return [PublicBrokerageResponse.model_validate(b) for b in brokerages]
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).
245 Used by public pages like about page and agent listings.
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 )
256 # Filter by brokerage if slug provided
257 if brokerage_slug:
258 query = query.join(Brokerage).where(Brokerage.slug == brokerage_slug)
260 agents = db.scalars(query.order_by(Agent.name)).all()
262 return [PublicAgentResponse.model_validate(a) for a in agents]
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).
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 )
281 if not agent:
282 from fastapi import HTTPException
283 raise HTTPException(status_code=404, detail="Agent not found")
285 return PublicAgentResponse.model_validate(agent)
288# ===== Site Config Endpoint =====
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('-')
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]
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]
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
327 if brokerage.nav_footer:
328 footer_items = [NavItem(**item) for item in brokerage.nav_footer]
329 else:
330 footer_items = DEFAULT_NAV_FOOTER
332 return NavigationSection(main=main_items, footer=footer_items)
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
344 # Determine site URL
345 site_url = brokerage.website or f"https://{domain}"
347 # Use primary color or default orange
348 primary_color = brokerage.primary_color or "#ee7711"
349 accent_color = primary_color
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
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 )
364 # Build API base URL from request
365 api_base_url = str(request.base_url).rstrip("/")
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 )
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).
487 This endpoint enables white-label multi-tenant support by returning
488 brokerage-specific configuration based on which domain is being accessed.
490 The domain must be:
491 1. Registered in the brokerage_domains table
492 2. Associated with an active (non-disabled) brokerage
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
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]
508 if not host:
509 raise HTTPException(status_code=400, detail="Missing Host header")
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 )
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 )
531 brokerage = domain_record.brokerage
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 )
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 )
549 # Build and return the site config
550 return _build_site_config(brokerage, primary_contact, host, request)