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

1"""Agent endpoints for real estate agent 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, 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 

19 

20router = APIRouter() 

21 

22 

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

24 

25 

26class AgentResponse(BaseModel): 

27 """Agent response model.""" 

28 

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 

45 

46 class Config: 

47 from_attributes = True 

48 

49 

50class AgentCreate(BaseModel): 

51 """Agent creation request.""" 

52 

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" 

65 

66 

67class AgentUpdate(BaseModel): 

68 """Agent update request.""" 

69 

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 

82 

83 

84class AgentSearchResult(BaseModel): 

85 """Agent search result with similarity score.""" 

86 

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 

94 

95 class Config: 

96 from_attributes = True 

97 

98 

99# ===== CRUD Endpoints ===== 

100 

101 

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. 

111 

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)] 

117 

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) 

121 

122 # Count total (excluding soft-deleted) 

123 total = db.scalar( 

124 select(func.count()).select_from(Agent).where(*base_where) 

125 ) 

126 

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() 

136 

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

138 

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 } 

146 

147 

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. 

156 

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") 

162 

163 return AgentResponse.model_validate(agent) 

164 

165 

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. 

174 

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") 

181 

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") 

186 

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") 

191 

192 agent = Agent( 

193 **data.model_dump(), 

194 created_at=datetime.utcnow(), 

195 updated_at=datetime.utcnow(), 

196 ) 

197 

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 

209 

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}") 

217 

218 return AgentResponse.model_validate(agent) 

219 

220 

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. 

230 

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") 

239 

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") 

254 

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) 

259 

260 agent.updated_at = datetime.utcnow() 

261 

262 db.commit() 

263 db.refresh(agent) 

264 

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}") 

272 

273 return AgentResponse.model_validate(agent) 

274 

275 

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. 

284 

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") 

290 

291 agent.disabled_at = datetime.utcnow() 

292 agent.updated_at = datetime.utcnow() 

293 

294 db.commit() 

295 

296 return {"message": "Agent deleted successfully"} 

297 

298 

299# ===== Search Endpoint ===== 

300 

301 

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. 

311 

312 Examples: 

313 - "native Idaho real estate expert" 

314 - "luxury home specialist" 

315 - "first-time homebuyer expert" 

316 

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 )