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
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
1"""Brokerage endpoints for real estate firm management."""
3from datetime import datetime
5from fastapi import APIRouter, Depends, HTTPException, Query
6from pydantic import BaseModel
7from sqlalchemy import func, select
8from sqlalchemy.orm import Session
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
20router = APIRouter()
23# ===== Response Models =====
26class BrokerageResponse(BaseModel):
27 """Brokerage response model."""
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
86 class Config:
87 from_attributes = True
90class BrokerageCreate(BaseModel):
91 """Brokerage creation request."""
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
146class BrokerageUpdate(BaseModel):
147 """Brokerage update request."""
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
201class BrokerageSearchResult(BaseModel):
202 """Brokerage search result with similarity score."""
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
214 class Config:
215 from_attributes = True
218# ===== CRUD Endpoints =====
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.
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 )
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()
248 total_pages = (total + page_size - 1) // page_size
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 }
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.
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")
274 return BrokerageResponse.model_validate(brokerage)
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.
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")
293 brokerage = Brokerage(
294 **data.model_dump(),
295 created_at=datetime.utcnow(),
296 updated_at=datetime.utcnow(),
297 )
299 db.add(brokerage)
300 db.commit()
301 db.refresh(brokerage)
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}")
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)
315 return BrokerageResponse.model_validate(brokerage)
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.
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")
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)
339 brokerage.updated_at = datetime.utcnow()
341 db.commit()
342 db.refresh(brokerage)
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}")
352 # Invalidate Astro frontend cache for this brokerage's domains
353 await invalidate_brokerage_domains(brokerage.id, db)
355 return BrokerageResponse.model_validate(brokerage)
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.
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")
373 # Invalidate cache before soft-delete (still have domains)
374 await invalidate_brokerage_domains(brokerage.id, db)
376 brokerage.disabled_at = datetime.utcnow()
377 brokerage.updated_at = datetime.utcnow()
379 db.commit()
381 return {"message": "Brokerage deleted successfully"}
384# ===== Search Endpoint =====
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.
397 Examples:
398 - "military relocation specialist"
399 - "RE/MAX franchise"
400 - "VA loan expert in Boise"
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 )