Coverage for src / idx_api / routers / agents.py: 85%
152 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"""Agent endpoints for real estate agent 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, AgentUser, BrokerUser, RequiredUser
11from idx_api.database import get_db
12from idx_api.embeddings import (
13 build_agent_text,
14 index_agent,
15 search_agents,
16)
17from idx_api.models.agent import Agent
18from idx_api.models.brokerage import Brokerage
20router = APIRouter()
23# ===== Response Models =====
26class AgentResponse(BaseModel):
27 """Agent response model."""
29 id: int
30 brokerage_id: int
31 name: str
32 slug: str
33 email: str
34 phone: str | None
35 mls_id: str | None
36 bio: str | None
37 photo_url: str | None
38 profile_url: str | None
39 is_lead_recipient: bool
40 is_primary: bool
41 status: str
42 disabled_at: datetime | None
43 created_at: datetime
44 updated_at: datetime
46 class Config:
47 from_attributes = True
50class AgentCreate(BaseModel):
51 """Agent creation request."""
53 brokerage_id: int
54 name: str
55 slug: str
56 email: str
57 phone: str | None = None
58 mls_id: str | None = None
59 bio: str | None = None
60 photo_url: str | None = None
61 profile_url: str | None = None
62 is_lead_recipient: bool = True
63 is_primary: bool = False
64 status: str = "active"
67class AgentUpdate(BaseModel):
68 """Agent update request."""
70 brokerage_id: int | None = None
71 name: str | None = None
72 slug: str | None = None
73 email: str | None = None
74 phone: str | None = None
75 mls_id: str | None = None
76 bio: str | None = None
77 photo_url: str | None = None
78 profile_url: str | None = None
79 is_lead_recipient: bool | None = None
80 is_primary: bool | None = None
81 status: str | None = None
84class AgentSearchResult(BaseModel):
85 """Agent search result with similarity score."""
87 id: int
88 name: str
89 email: str
90 phone: str | None
91 bio: str | None
92 brokerage_name: str | None
93 similarity: float
95 class Config:
96 from_attributes = True
99# ===== CRUD Endpoints =====
102@router.get("/agents")
103async def list_agents(
104 user: RequiredUser,
105 db: Session = Depends(get_db),
106 page: int = Query(1, ge=1),
107 page_size: int = Query(20, ge=1, le=100),
108):
109 """
110 List agents with pagination.
112 - Admins see all agents
113 - Brokers/agents see only agents in their brokerage
114 """
115 # Build base query
116 base_where = [Agent.disabled_at.is_(None)]
118 # Filter by brokerage for non-admins
119 if user.role != "admin" and user.brokerage_id:
120 base_where.append(Agent.brokerage_id == user.brokerage_id)
122 # Count total (excluding soft-deleted)
123 total = db.scalar(
124 select(func.count()).select_from(Agent).where(*base_where)
125 )
127 # Get paginated results
128 offset = (page - 1) * page_size
129 agents = db.scalars(
130 select(Agent)
131 .where(*base_where)
132 .order_by(Agent.created_at.desc())
133 .offset(offset)
134 .limit(page_size)
135 ).all()
137 total_pages = (total + page_size - 1) // page_size
139 return {
140 "items": [AgentResponse.model_validate(a) for a in agents],
141 "total": total,
142 "page": page,
143 "page_size": page_size,
144 "total_pages": total_pages,
145 }
148@router.get("/agents/{agent_id}", response_model=AgentResponse)
149async def get_agent(
150 agent_id: int,
151 user: RequiredUser,
152 db: Session = Depends(get_db),
153):
154 """
155 Get a specific agent by ID.
157 Accessible to all authenticated users.
158 """
159 agent = db.get(Agent, agent_id)
160 if not agent or agent.disabled_at:
161 raise HTTPException(status_code=404, detail="Agent not found")
163 return AgentResponse.model_validate(agent)
166@router.post("/agents", response_model=AgentResponse)
167async def create_agent(
168 data: AgentCreate,
169 user: BrokerUser,
170 db: Session = Depends(get_db),
171):
172 """
173 Create a new agent.
175 Requires admin or broker role.
176 Brokers can only create agents for their own brokerage.
177 """
178 # Brokers can only create agents for their own brokerage
179 if user.role == "broker" and user.brokerage_id != data.brokerage_id:
180 raise HTTPException(status_code=403, detail="Cannot create agent for another brokerage")
182 # Verify brokerage exists
183 brokerage = db.get(Brokerage, data.brokerage_id)
184 if not brokerage or brokerage.disabled_at:
185 raise HTTPException(status_code=400, detail="Invalid brokerage")
187 # Check for duplicate slug
188 existing = db.scalar(select(Agent).where(Agent.slug == data.slug))
189 if existing:
190 raise HTTPException(status_code=400, detail="Agent slug already exists")
192 agent = Agent(
193 **data.model_dump(),
194 created_at=datetime.utcnow(),
195 updated_at=datetime.utcnow(),
196 )
198 # Insert with race condition handling for unique slug constraint
199 try:
200 db.add(agent)
201 db.commit()
202 db.refresh(agent)
203 except Exception as e:
204 db.rollback()
205 # Check if it was a slug conflict
206 if "UNIQUE constraint" in str(e) or "slug" in str(e).lower():
207 raise HTTPException(status_code=400, detail="Agent slug already exists")
208 raise
210 # Index for vector search
211 try:
212 text_content = build_agent_text(agent)
213 index_agent(db, agent.id, text_content)
214 db.commit()
215 except Exception as e:
216 print(f"⚠️ Failed to index agent {agent.id}: {e}")
218 return AgentResponse.model_validate(agent)
221@router.put("/agents/{agent_id}", response_model=AgentResponse)
222async def update_agent(
223 agent_id: int,
224 data: AgentUpdate,
225 user: RequiredUser,
226 db: Session = Depends(get_db),
227):
228 """
229 Update an existing agent.
231 Authorization:
232 - Admins can update any agent
233 - Brokers can update agents in their brokerage
234 - Agents can update their own profile
235 """
236 agent = db.get(Agent, agent_id)
237 if not agent or agent.disabled_at:
238 raise HTTPException(status_code=404, detail="Agent not found")
240 # Authorization checks
241 if user.role == "admin":
242 # Admins can update any agent
243 pass
244 elif user.role == "broker":
245 # Brokers can only update agents in their brokerage
246 if user.brokerage_id != agent.brokerage_id:
247 raise HTTPException(status_code=403, detail="Cannot update agent from another brokerage")
248 elif user.role == "agent":
249 # Agents can only update their own profile
250 if user.agent_id != agent_id:
251 raise HTTPException(status_code=403, detail="Cannot update other agents")
252 else:
253 raise HTTPException(status_code=403, detail="Insufficient permissions")
255 # Update fields
256 update_data = data.model_dump(exclude_unset=True)
257 for field, value in update_data.items():
258 setattr(agent, field, value)
260 agent.updated_at = datetime.utcnow()
262 db.commit()
263 db.refresh(agent)
265 # Re-index for vector search
266 try:
267 text_content = build_agent_text(agent)
268 index_agent(db, agent.id, text_content)
269 db.commit()
270 except Exception as e:
271 print(f"⚠️ Failed to index agent {agent.id}: {e}")
273 return AgentResponse.model_validate(agent)
276@router.delete("/agents/{agent_id}")
277async def delete_agent(
278 agent_id: int,
279 user: AdminUser,
280 db: Session = Depends(get_db),
281):
282 """
283 Soft-delete an agent.
285 Requires admin role.
286 """
287 agent = db.get(Agent, agent_id)
288 if not agent:
289 raise HTTPException(status_code=404, detail="Agent not found")
291 agent.disabled_at = datetime.utcnow()
292 agent.updated_at = datetime.utcnow()
294 db.commit()
296 return {"message": "Agent deleted successfully"}
299# ===== Search Endpoint =====
302@router.get("/search/agents")
303async def semantic_search_agents(
304 user: RequiredUser,
305 db: Session = Depends(get_db),
306 q: str = Query(..., min_length=2, description="Search query"),
307 limit: int = Query(10, ge=1, le=50, description="Maximum results"),
308):
309 """
310 Search agents using semantic similarity.
312 Examples:
313 - "native Idaho real estate expert"
314 - "luxury home specialist"
315 - "first-time homebuyer expert"
317 Uses embeddings to find semantically similar agents, not just keyword matches.
318 """
319 try:
320 results = search_agents(db, query=q, limit=limit)
321 return {
322 "query": q,
323 "results": [AgentSearchResult.model_validate(r) for r in results],
324 "total": len(results),
325 }
326 except Exception as e:
327 raise HTTPException(
328 status_code=503,
329 detail=f"Search service unavailable: {str(e)}",
330 )