Coverage for src / idx_api / routers / broker_contacts.py: 49%
136 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"""Broker contact endpoints for managing contact persons at brokerages."""
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, BrokerUser, RequiredUser
11from idx_api.database import get_db
12from idx_api.embeddings import (
13 build_broker_contact_text,
14 index_broker_contact,
15 search_broker_contacts,
16)
17from idx_api.models.broker import Broker
19router = APIRouter()
22# ===== Response Models =====
25class BrokerContactResponse(BaseModel):
26 """Broker contact response model."""
28 id: int
29 brokerage_id: int
30 name: str
31 email: str
32 phone: str | None
33 phone_display: str | None
34 license_type: str | None
35 license_number: str | None
36 is_primary: bool
37 disabled_at: datetime | None
38 created_at: datetime
39 updated_at: datetime
41 class Config:
42 from_attributes = True
45class BrokerContactCreate(BaseModel):
46 """Broker contact creation request."""
48 brokerage_id: int
49 name: str
50 email: str
51 phone: str | None = None
52 phone_display: str | None = None
53 license_type: str | None = None
54 license_number: str | None = None
55 is_primary: bool = False
58class BrokerContactUpdate(BaseModel):
59 """Broker contact update request."""
61 brokerage_id: int | None = None
62 name: str | None = None
63 email: str | None = None
64 phone: str | None = None
65 phone_display: str | None = None
66 license_type: str | None = None
67 license_number: str | None = None
68 is_primary: bool | None = None
71class BrokerContactSearchResult(BaseModel):
72 """Broker contact search result with similarity score."""
74 id: int
75 brokerage_id: int
76 name: str
77 email: str
78 phone: str | None
79 license_type: str | None
80 is_primary: bool
81 similarity: float
83 class Config:
84 from_attributes = True
87# ===== CRUD Endpoints =====
90@router.get("/broker-contacts")
91async def list_broker_contacts(
92 user: RequiredUser,
93 db: Session = Depends(get_db),
94 brokerage_id: int | None = Query(None, description="Filter by brokerage ID"),
95 page: int = Query(1, ge=1),
96 page_size: int = Query(20, ge=1, le=100),
97):
98 """
99 List broker contacts with pagination.
101 - Admins see all broker contacts
102 - Brokers see only contacts in their brokerage
103 - Can filter by brokerage_id
104 """
105 # Build base query
106 base_where = [Broker.disabled_at.is_(None)]
108 # Filter by brokerage
109 if brokerage_id:
110 base_where.append(Broker.brokerage_id == brokerage_id)
111 elif user.role != "admin" and user.brokerage_id:
112 # Non-admins can only see contacts from their own brokerage
113 base_where.append(Broker.brokerage_id == user.brokerage_id)
115 # Count total (excluding soft-deleted)
116 total = db.scalar(
117 select(func.count()).select_from(Broker).where(*base_where)
118 )
120 # Get paginated results
121 offset = (page - 1) * page_size
122 contacts = db.scalars(
123 select(Broker)
124 .where(*base_where)
125 .order_by(Broker.is_primary.desc(), Broker.created_at.desc())
126 .offset(offset)
127 .limit(page_size)
128 ).all()
130 total_pages = (total + page_size - 1) // page_size
132 return {
133 "items": [BrokerContactResponse.model_validate(c) for c in contacts],
134 "total": total,
135 "page": page,
136 "page_size": page_size,
137 "total_pages": total_pages,
138 }
141@router.get("/broker-contacts/{contact_id}", response_model=BrokerContactResponse)
142async def get_broker_contact(
143 contact_id: int,
144 user: RequiredUser,
145 db: Session = Depends(get_db),
146):
147 """
148 Get a single broker contact by ID.
150 - Admins can access any contact
151 - Non-admins can only access contacts from their brokerage
152 """
153 contact = db.get(Broker, contact_id)
154 if not contact or contact.disabled_at:
155 raise HTTPException(status_code=404, detail="Broker contact not found")
157 # Authorization check for non-admins
158 if user.role != "admin" and user.brokerage_id != contact.brokerage_id:
159 raise HTTPException(status_code=403, detail="Access denied")
161 return BrokerContactResponse.model_validate(contact)
164@router.post("/broker-contacts", response_model=BrokerContactResponse)
165async def create_broker_contact(
166 data: BrokerContactCreate,
167 user: BrokerUser, # Broker or admin can create contacts
168 db: Session = Depends(get_db),
169):
170 """
171 Create a new broker contact.
173 Requires broker or admin role.
175 If is_primary=True, will unset is_primary on other contacts for that brokerage.
176 """
177 # Authorization: non-admins can only create contacts for their brokerage
178 if user.role != "admin" and user.brokerage_id != data.brokerage_id:
179 raise HTTPException(status_code=403, detail="Can only create contacts for your own brokerage")
181 # If setting as primary, unset primary for other contacts in this brokerage
182 if data.is_primary:
183 existing_primary = db.scalars(
184 select(Broker).where(
185 Broker.brokerage_id == data.brokerage_id,
186 Broker.is_primary == True,
187 Broker.disabled_at.is_(None)
188 )
189 ).all()
190 for contact in existing_primary:
191 contact.is_primary = False
192 contact.updated_at = datetime.utcnow()
194 contact = Broker(
195 **data.model_dump(),
196 created_at=datetime.utcnow(),
197 updated_at=datetime.utcnow(),
198 )
200 db.add(contact)
201 db.commit()
202 db.refresh(contact)
204 # Index for vector search
205 try:
206 text_content = build_broker_contact_text(contact)
207 index_broker_contact(db, contact.id, text_content)
208 db.commit()
209 except Exception as e:
210 print(f"⚠️ Failed to index broker contact {contact.id}: {e}")
212 return BrokerContactResponse.model_validate(contact)
215@router.put("/broker-contacts/{contact_id}", response_model=BrokerContactResponse)
216async def update_broker_contact(
217 contact_id: int,
218 data: BrokerContactUpdate,
219 user: BrokerUser,
220 db: Session = Depends(get_db),
221):
222 """
223 Update an existing broker contact.
225 Requires broker or admin role.
227 If setting is_primary=True, will unset is_primary on other contacts for that brokerage.
228 """
229 contact = db.get(Broker, contact_id)
230 if not contact or contact.disabled_at:
231 raise HTTPException(status_code=404, detail="Broker contact not found")
233 # Authorization: non-admins can only edit contacts from their brokerage
234 if user.role != "admin" and user.brokerage_id != contact.brokerage_id:
235 raise HTTPException(status_code=403, detail="Access denied")
237 # Update fields
238 update_data = data.model_dump(exclude_unset=True)
240 # If setting as primary, unset primary for other contacts in this brokerage
241 if update_data.get("is_primary"):
242 brokerage_id = update_data.get("brokerage_id", contact.brokerage_id)
243 existing_primary = db.scalars(
244 select(Broker).where(
245 Broker.brokerage_id == brokerage_id,
246 Broker.is_primary == True,
247 Broker.id != contact_id,
248 Broker.disabled_at.is_(None)
249 )
250 ).all()
251 for other_contact in existing_primary:
252 other_contact.is_primary = False
253 other_contact.updated_at = datetime.utcnow()
255 for field, value in update_data.items():
256 setattr(contact, field, value)
258 contact.updated_at = datetime.utcnow()
260 db.commit()
261 db.refresh(contact)
263 # Re-index for vector search
264 try:
265 text_content = build_broker_contact_text(contact)
266 index_broker_contact(db, contact.id, text_content)
267 db.commit()
268 except Exception as e:
269 print(f"⚠️ Failed to index broker contact {contact.id}: {e}")
271 return BrokerContactResponse.model_validate(contact)
274@router.delete("/broker-contacts/{contact_id}")
275async def delete_broker_contact(
276 contact_id: int,
277 user: AdminUser, # Only admins can delete contacts
278 db: Session = Depends(get_db),
279):
280 """
281 Soft-delete a broker contact.
283 Requires admin role.
284 """
285 contact = db.get(Broker, contact_id)
286 if not contact:
287 raise HTTPException(status_code=404, detail="Broker contact not found")
289 contact.disabled_at = datetime.utcnow()
290 contact.updated_at = datetime.utcnow()
292 db.commit()
294 return {"message": "Broker contact deleted successfully"}
297# ===== Search Endpoint =====
300@router.get("/search/broker-contacts")
301async def semantic_search_broker_contacts(
302 user: RequiredUser,
303 db: Session = Depends(get_db),
304 q: str = Query(..., min_length=2, description="Search query"),
305 brokerage_id: int | None = Query(None, description="Filter by brokerage ID"),
306 limit: int = Query(10, ge=1, le=50, description="Maximum results"),
307):
308 """
309 Search broker contacts using semantic similarity.
311 Examples:
312 - "military relocation expert"
313 - "licensed real estate broker"
314 - "primary contact for RE/MAX"
316 Uses embeddings to find semantically similar broker contacts, not just keyword matches.
317 """
318 try:
319 results = search_broker_contacts(
320 db,
321 query=q,
322 brokerage_id=brokerage_id,
323 limit=limit
324 )
325 return {
326 "query": q,
327 "results": [BrokerContactSearchResult.model_validate(r) for r in results],
328 "total": len(results),
329 }
330 except Exception as e:
331 raise HTTPException(
332 status_code=503,
333 detail=f"Search service unavailable: {str(e)}",
334 )