Coverage for src / idx_api / routers / clients.py: 50%
398 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"""CRM Client endpoints for managing clients, contacts, and activities."""
3from datetime import datetime, timezone
4from typing import Optional
6from fastapi import APIRouter, Depends, HTTPException, Query
7from pydantic import BaseModel
8from sqlalchemy import func, select
9from sqlalchemy.orm import Session, selectinload
11from idx_api.auth import BrokerUser, RequiredUser
12from idx_api.database import get_db
13from idx_api.models.client import (
14 Client,
15 ClientActivity,
16 ClientContact,
17 ACTIVITY_TYPES,
18 BUYER_STATUSES,
19 CLIENT_TYPES,
20 CONTACT_ROLES,
21 INTEREST_TYPES,
22 SELLER_STATUSES,
23)
25router = APIRouter()
28# ===== Response Models =====
31class ClientContactResponse(BaseModel):
32 """Client contact response model."""
34 id: int
35 client_id: int
36 first_name: str
37 last_name: str
38 email: str | None
39 phone: str | None
40 phone_display: str | None
41 role: str
42 is_primary: bool
43 address_line1: str | None
44 address_line2: str | None
45 city: str | None
46 state: str | None
47 zip_code: str | None
48 disabled_at: datetime | None
49 created_at: datetime
50 updated_at: datetime
52 class Config:
53 from_attributes = True
56class ClientActivityResponse(BaseModel):
57 """Client activity response model."""
59 id: int
60 client_id: int
61 agent_id: int | None
62 property_id: str | None
63 activity_type: str
64 title: str
65 description: str | None
66 activity_at: datetime
67 outcome: str | None
68 follow_up_at: datetime | None
69 created_at: datetime
70 updated_at: datetime
72 class Config:
73 from_attributes = True
76class ClientResponse(BaseModel):
77 """Client response model."""
79 id: int
80 brokerage_id: int
81 assigned_agent_id: int | None
82 client_type: str
83 display_name: str
84 entity_name: str | None
85 interest_type: str
86 buyer_status: str | None
87 seller_status: str | None
88 budget_min: int | None
89 budget_max: int | None
90 beds_min: int | None
91 baths_min: float | None
92 property_types: str | None
93 preferred_areas: str | None
94 listing_address: str | None
95 expected_price: int | None
96 listing_timeline: str | None
97 source: str | None
98 referral_name: str | None
99 tags: str | None
100 notes: str | None
101 first_contact_at: datetime | None
102 last_contact_at: datetime | None
103 disabled_at: datetime | None
104 created_at: datetime
105 updated_at: datetime
107 class Config:
108 from_attributes = True
111class ClientDetailResponse(ClientResponse):
112 """Client response with nested contacts."""
114 contacts: list[ClientContactResponse] = []
117# ===== Request Models =====
120class ClientCreate(BaseModel):
121 """Client creation request."""
123 brokerage_id: int
124 assigned_agent_id: int | None = None
125 client_type: str = "person"
126 display_name: str
127 entity_name: str | None = None
128 interest_type: str = "buyer"
129 buyer_status: str | None = "lead"
130 seller_status: str | None = None
131 budget_min: int | None = None
132 budget_max: int | None = None
133 beds_min: int | None = None
134 baths_min: float | None = None
135 property_types: str | None = None
136 preferred_areas: str | None = None
137 listing_address: str | None = None
138 expected_price: int | None = None
139 listing_timeline: str | None = None
140 source: str | None = None
141 referral_name: str | None = None
142 tags: str | None = None
143 notes: str | None = None
144 first_contact_at: datetime | None = None
147class ClientUpdate(BaseModel):
148 """Client update request."""
150 assigned_agent_id: int | None = None
151 client_type: str | None = None
152 display_name: str | None = None
153 entity_name: str | None = None
154 interest_type: str | None = None
155 buyer_status: str | None = None
156 seller_status: str | None = None
157 budget_min: int | None = None
158 budget_max: int | None = None
159 beds_min: int | None = None
160 baths_min: float | None = None
161 property_types: str | None = None
162 preferred_areas: str | None = None
163 listing_address: str | None = None
164 expected_price: int | None = None
165 listing_timeline: str | None = None
166 source: str | None = None
167 referral_name: str | None = None
168 tags: str | None = None
169 notes: str | None = None
172class ClientContactCreate(BaseModel):
173 """Client contact creation request."""
175 first_name: str
176 last_name: str
177 email: str | None = None
178 phone: str | None = None
179 phone_display: str | None = None
180 role: str = "primary"
181 is_primary: bool = True
182 address_line1: str | None = None
183 address_line2: str | None = None
184 city: str | None = None
185 state: str | None = None
186 zip_code: str | None = None
189class ClientContactUpdate(BaseModel):
190 """Client contact update request."""
192 first_name: str | None = None
193 last_name: str | None = None
194 email: str | None = None
195 phone: str | None = None
196 phone_display: str | None = None
197 role: str | None = None
198 is_primary: bool | None = None
199 address_line1: str | None = None
200 address_line2: str | None = None
201 city: str | None = None
202 state: str | None = None
203 zip_code: str | None = None
206class ClientActivityCreate(BaseModel):
207 """Client activity creation request."""
209 agent_id: int | None = None
210 property_id: str | None = None
211 activity_type: str = "note"
212 title: str
213 description: str | None = None
214 activity_at: datetime
215 outcome: str | None = None
216 follow_up_at: datetime | None = None
219class ClientActivityUpdate(BaseModel):
220 """Client activity update request."""
222 agent_id: int | None = None
223 property_id: str | None = None
224 activity_type: str | None = None
225 title: str | None = None
226 description: str | None = None
227 activity_at: datetime | None = None
228 outcome: str | None = None
229 follow_up_at: datetime | None = None
232# ===== Helper Functions =====
235def _now() -> datetime:
236 return datetime.now(timezone.utc)
239def _validate_client_type(client_type: str) -> None:
240 if client_type not in CLIENT_TYPES:
241 raise HTTPException(
242 status_code=400,
243 detail=f"Invalid client_type. Must be one of: {', '.join(CLIENT_TYPES)}",
244 )
247def _validate_interest_type(interest_type: str) -> None:
248 if interest_type not in INTEREST_TYPES:
249 raise HTTPException(
250 status_code=400,
251 detail=f"Invalid interest_type. Must be one of: {', '.join(INTEREST_TYPES)}",
252 )
255def _validate_buyer_status(status: str | None) -> None:
256 if status and status not in BUYER_STATUSES:
257 raise HTTPException(
258 status_code=400,
259 detail=f"Invalid buyer_status. Must be one of: {', '.join(BUYER_STATUSES)}",
260 )
263def _validate_seller_status(status: str | None) -> None:
264 if status and status not in SELLER_STATUSES:
265 raise HTTPException(
266 status_code=400,
267 detail=f"Invalid seller_status. Must be one of: {', '.join(SELLER_STATUSES)}",
268 )
271def _validate_contact_role(role: str) -> None:
272 if role not in CONTACT_ROLES:
273 raise HTTPException(
274 status_code=400,
275 detail=f"Invalid role. Must be one of: {', '.join(CONTACT_ROLES)}",
276 )
279def _validate_activity_type(activity_type: str) -> None:
280 if activity_type not in ACTIVITY_TYPES:
281 raise HTTPException(
282 status_code=400,
283 detail=f"Invalid activity_type. Must be one of: {', '.join(ACTIVITY_TYPES)}",
284 )
287# ===== Client CRUD Endpoints =====
290# NOTE: Static routes must be defined BEFORE parameterized routes like /clients/{client_id}
291@router.get("/clients/enums")
292async def get_client_enums():
293 """Get all valid enum values for client fields."""
294 return {
295 "client_types": CLIENT_TYPES,
296 "interest_types": INTEREST_TYPES,
297 "buyer_statuses": BUYER_STATUSES,
298 "seller_statuses": SELLER_STATUSES,
299 "contact_roles": CONTACT_ROLES,
300 "activity_types": ACTIVITY_TYPES,
301 }
304@router.get("/clients")
305async def list_clients(
306 user: RequiredUser,
307 db: Session = Depends(get_db),
308 brokerage_id: int | None = Query(None, description="Filter by brokerage ID"),
309 assigned_agent_id: int | None = Query(None, description="Filter by assigned agent"),
310 interest_type: str | None = Query(None, description="Filter by interest type"),
311 buyer_status: str | None = Query(None, description="Filter by buyer status"),
312 seller_status: str | None = Query(None, description="Filter by seller status"),
313 search: str | None = Query(None, description="Search by display name"),
314 page: int = Query(1, ge=1),
315 page_size: int = Query(20, ge=1, le=100),
316):
317 """
318 List clients with pagination and filters.
320 - Admins see all clients
321 - Non-admins see only clients in their brokerage
322 """
323 base_where = [Client.disabled_at.is_(None)]
325 # Brokerage filter
326 if brokerage_id:
327 base_where.append(Client.brokerage_id == brokerage_id)
328 elif user.role != "admin" and user.brokerage_id:
329 base_where.append(Client.brokerage_id == user.brokerage_id)
331 # Additional filters
332 if assigned_agent_id:
333 base_where.append(Client.assigned_agent_id == assigned_agent_id)
334 if interest_type:
335 base_where.append(Client.interest_type == interest_type)
336 if buyer_status:
337 base_where.append(Client.buyer_status == buyer_status)
338 if seller_status:
339 base_where.append(Client.seller_status == seller_status)
340 if search:
341 base_where.append(Client.display_name.ilike(f"%{search}%"))
343 # Count total
344 total = db.scalar(select(func.count()).select_from(Client).where(*base_where))
346 # Get paginated results
347 offset = (page - 1) * page_size
348 clients = db.scalars(
349 select(Client)
350 .where(*base_where)
351 .order_by(Client.last_contact_at.desc().nullslast(), Client.created_at.desc())
352 .offset(offset)
353 .limit(page_size)
354 ).all()
356 total_pages = (total + page_size - 1) // page_size
358 return {
359 "items": [ClientResponse.model_validate(c) for c in clients],
360 "total": total,
361 "page": page,
362 "page_size": page_size,
363 "total_pages": total_pages,
364 }
367@router.get("/clients/{client_id}", response_model=ClientDetailResponse)
368async def get_client(
369 client_id: int,
370 user: RequiredUser,
371 db: Session = Depends(get_db),
372):
373 """Get a single client by ID with contacts."""
374 client = db.scalars(
375 select(Client)
376 .where(Client.id == client_id)
377 .options(selectinload(Client.contacts))
378 ).first()
380 if not client or client.disabled_at:
381 raise HTTPException(status_code=404, detail="Client not found")
383 # Authorization check
384 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
385 raise HTTPException(status_code=403, detail="Access denied")
387 # Filter out disabled contacts
388 active_contacts = [c for c in client.contacts if not c.disabled_at]
390 response = ClientDetailResponse.model_validate(client)
391 response.contacts = [ClientContactResponse.model_validate(c) for c in active_contacts]
393 return response
396@router.post("/clients", response_model=ClientResponse)
397async def create_client(
398 data: ClientCreate,
399 user: BrokerUser,
400 db: Session = Depends(get_db),
401):
402 """Create a new client."""
403 # Authorization
404 if user.role != "admin" and user.brokerage_id != data.brokerage_id:
405 raise HTTPException(
406 status_code=403, detail="Can only create clients for your own brokerage"
407 )
409 # Validation
410 _validate_client_type(data.client_type)
411 _validate_interest_type(data.interest_type)
412 _validate_buyer_status(data.buyer_status)
413 _validate_seller_status(data.seller_status)
415 now = _now()
416 client = Client(
417 **data.model_dump(),
418 first_contact_at=data.first_contact_at or now,
419 last_contact_at=now,
420 created_at=now,
421 updated_at=now,
422 )
424 db.add(client)
425 db.commit()
426 db.refresh(client)
428 return ClientResponse.model_validate(client)
431@router.put("/clients/{client_id}", response_model=ClientResponse)
432async def update_client(
433 client_id: int,
434 data: ClientUpdate,
435 user: BrokerUser,
436 db: Session = Depends(get_db),
437):
438 """Update an existing client."""
439 client = db.get(Client, client_id)
440 if not client or client.disabled_at:
441 raise HTTPException(status_code=404, detail="Client not found")
443 # Authorization
444 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
445 raise HTTPException(status_code=403, detail="Access denied")
447 # Validation
448 update_data = data.model_dump(exclude_unset=True)
449 if "client_type" in update_data:
450 _validate_client_type(update_data["client_type"])
451 if "interest_type" in update_data:
452 _validate_interest_type(update_data["interest_type"])
453 if "buyer_status" in update_data:
454 _validate_buyer_status(update_data["buyer_status"])
455 if "seller_status" in update_data:
456 _validate_seller_status(update_data["seller_status"])
458 for field, value in update_data.items():
459 setattr(client, field, value)
461 client.updated_at = _now()
462 db.commit()
463 db.refresh(client)
465 return ClientResponse.model_validate(client)
468@router.delete("/clients/{client_id}")
469async def delete_client(
470 client_id: int,
471 user: BrokerUser,
472 db: Session = Depends(get_db),
473):
474 """Soft-delete a client."""
475 client = db.get(Client, client_id)
476 if not client:
477 raise HTTPException(status_code=404, detail="Client not found")
479 # Authorization
480 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
481 raise HTTPException(status_code=403, detail="Access denied")
483 now = _now()
484 client.disabled_at = now
485 client.updated_at = now
486 db.commit()
488 return {"message": "Client deleted successfully"}
491# ===== Client Contact CRUD Endpoints =====
494@router.get("/clients/{client_id}/contacts")
495async def list_client_contacts(
496 client_id: int,
497 user: RequiredUser,
498 db: Session = Depends(get_db),
499):
500 """List contacts for a client."""
501 client = db.get(Client, client_id)
502 if not client or client.disabled_at:
503 raise HTTPException(status_code=404, detail="Client not found")
505 # Authorization
506 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
507 raise HTTPException(status_code=403, detail="Access denied")
509 contacts = db.scalars(
510 select(ClientContact)
511 .where(ClientContact.client_id == client_id, ClientContact.disabled_at.is_(None))
512 .order_by(ClientContact.is_primary.desc(), ClientContact.created_at.asc())
513 ).all()
515 return {"items": [ClientContactResponse.model_validate(c) for c in contacts]}
518@router.post("/clients/{client_id}/contacts", response_model=ClientContactResponse)
519async def create_client_contact(
520 client_id: int,
521 data: ClientContactCreate,
522 user: BrokerUser,
523 db: Session = Depends(get_db),
524):
525 """Create a new contact for a client."""
526 client = db.get(Client, client_id)
527 if not client or client.disabled_at:
528 raise HTTPException(status_code=404, detail="Client not found")
530 # Authorization
531 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
532 raise HTTPException(status_code=403, detail="Access denied")
534 # Validation
535 _validate_contact_role(data.role)
537 # If setting as primary, unset primary for other contacts
538 if data.is_primary:
539 existing_primary = db.scalars(
540 select(ClientContact).where(
541 ClientContact.client_id == client_id,
542 ClientContact.is_primary == True,
543 ClientContact.disabled_at.is_(None),
544 )
545 ).all()
546 now = _now()
547 for contact in existing_primary:
548 contact.is_primary = False
549 contact.updated_at = now
551 now = _now()
552 contact = ClientContact(
553 client_id=client_id,
554 **data.model_dump(),
555 created_at=now,
556 updated_at=now,
557 )
559 db.add(contact)
560 db.commit()
561 db.refresh(contact)
563 return ClientContactResponse.model_validate(contact)
566@router.put(
567 "/clients/{client_id}/contacts/{contact_id}", response_model=ClientContactResponse
568)
569async def update_client_contact(
570 client_id: int,
571 contact_id: int,
572 data: ClientContactUpdate,
573 user: BrokerUser,
574 db: Session = Depends(get_db),
575):
576 """Update an existing client contact."""
577 client = db.get(Client, client_id)
578 if not client or client.disabled_at:
579 raise HTTPException(status_code=404, detail="Client not found")
581 # Authorization
582 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
583 raise HTTPException(status_code=403, detail="Access denied")
585 contact = db.get(ClientContact, contact_id)
586 if not contact or contact.client_id != client_id or contact.disabled_at:
587 raise HTTPException(status_code=404, detail="Contact not found")
589 # Validation
590 update_data = data.model_dump(exclude_unset=True)
591 if "role" in update_data:
592 _validate_contact_role(update_data["role"])
594 # If setting as primary, unset primary for other contacts
595 if update_data.get("is_primary"):
596 existing_primary = db.scalars(
597 select(ClientContact).where(
598 ClientContact.client_id == client_id,
599 ClientContact.is_primary == True,
600 ClientContact.id != contact_id,
601 ClientContact.disabled_at.is_(None),
602 )
603 ).all()
604 now = _now()
605 for other_contact in existing_primary:
606 other_contact.is_primary = False
607 other_contact.updated_at = now
609 for field, value in update_data.items():
610 setattr(contact, field, value)
612 contact.updated_at = _now()
613 db.commit()
614 db.refresh(contact)
616 return ClientContactResponse.model_validate(contact)
619@router.delete("/clients/{client_id}/contacts/{contact_id}")
620async def delete_client_contact(
621 client_id: int,
622 contact_id: int,
623 user: BrokerUser,
624 db: Session = Depends(get_db),
625):
626 """Soft-delete a client contact."""
627 client = db.get(Client, client_id)
628 if not client or client.disabled_at:
629 raise HTTPException(status_code=404, detail="Client not found")
631 # Authorization
632 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
633 raise HTTPException(status_code=403, detail="Access denied")
635 contact = db.get(ClientContact, contact_id)
636 if not contact or contact.client_id != client_id:
637 raise HTTPException(status_code=404, detail="Contact not found")
639 now = _now()
640 contact.disabled_at = now
641 contact.updated_at = now
642 db.commit()
644 return {"message": "Contact deleted successfully"}
647# ===== Client Activity CRUD Endpoints =====
650@router.get("/clients/{client_id}/activities")
651async def list_client_activities(
652 client_id: int,
653 user: RequiredUser,
654 db: Session = Depends(get_db),
655 page: int = Query(1, ge=1),
656 page_size: int = Query(20, ge=1, le=100),
657):
658 """List activities for a client with pagination."""
659 client = db.get(Client, client_id)
660 if not client or client.disabled_at:
661 raise HTTPException(status_code=404, detail="Client not found")
663 # Authorization
664 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
665 raise HTTPException(status_code=403, detail="Access denied")
667 # Count total
668 total = db.scalar(
669 select(func.count())
670 .select_from(ClientActivity)
671 .where(ClientActivity.client_id == client_id)
672 )
674 # Get paginated results
675 offset = (page - 1) * page_size
676 activities = db.scalars(
677 select(ClientActivity)
678 .where(ClientActivity.client_id == client_id)
679 .order_by(ClientActivity.activity_at.desc())
680 .offset(offset)
681 .limit(page_size)
682 ).all()
684 total_pages = (total + page_size - 1) // page_size
686 return {
687 "items": [ClientActivityResponse.model_validate(a) for a in activities],
688 "total": total,
689 "page": page,
690 "page_size": page_size,
691 "total_pages": total_pages,
692 }
695@router.post("/clients/{client_id}/activities", response_model=ClientActivityResponse)
696async def create_client_activity(
697 client_id: int,
698 data: ClientActivityCreate,
699 user: BrokerUser,
700 db: Session = Depends(get_db),
701):
702 """Create a new activity for a client."""
703 client = db.get(Client, client_id)
704 if not client or client.disabled_at:
705 raise HTTPException(status_code=404, detail="Client not found")
707 # Authorization
708 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
709 raise HTTPException(status_code=403, detail="Access denied")
711 # Validation
712 _validate_activity_type(data.activity_type)
714 now = _now()
715 activity = ClientActivity(
716 client_id=client_id,
717 **data.model_dump(),
718 created_at=now,
719 updated_at=now,
720 )
722 db.add(activity)
724 # Update client's last_contact_at
725 client.last_contact_at = data.activity_at
726 client.updated_at = now
728 db.commit()
729 db.refresh(activity)
731 return ClientActivityResponse.model_validate(activity)
734@router.put(
735 "/clients/{client_id}/activities/{activity_id}",
736 response_model=ClientActivityResponse,
737)
738async def update_client_activity(
739 client_id: int,
740 activity_id: int,
741 data: ClientActivityUpdate,
742 user: BrokerUser,
743 db: Session = Depends(get_db),
744):
745 """Update an existing client activity."""
746 client = db.get(Client, client_id)
747 if not client or client.disabled_at:
748 raise HTTPException(status_code=404, detail="Client not found")
750 # Authorization
751 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
752 raise HTTPException(status_code=403, detail="Access denied")
754 activity = db.get(ClientActivity, activity_id)
755 if not activity or activity.client_id != client_id:
756 raise HTTPException(status_code=404, detail="Activity not found")
758 # Validation
759 update_data = data.model_dump(exclude_unset=True)
760 if "activity_type" in update_data:
761 _validate_activity_type(update_data["activity_type"])
763 for field, value in update_data.items():
764 setattr(activity, field, value)
766 activity.updated_at = _now()
767 db.commit()
768 db.refresh(activity)
770 return ClientActivityResponse.model_validate(activity)
773@router.delete("/clients/{client_id}/activities/{activity_id}")
774async def delete_client_activity(
775 client_id: int,
776 activity_id: int,
777 user: BrokerUser,
778 db: Session = Depends(get_db),
779):
780 """Delete a client activity (hard delete)."""
781 client = db.get(Client, client_id)
782 if not client or client.disabled_at:
783 raise HTTPException(status_code=404, detail="Client not found")
785 # Authorization
786 if user.role != "admin" and user.brokerage_id != client.brokerage_id:
787 raise HTTPException(status_code=403, detail="Access denied")
789 activity = db.get(ClientActivity, activity_id)
790 if not activity or activity.client_id != client_id:
791 raise HTTPException(status_code=404, detail="Activity not found")
793 db.delete(activity)
794 db.commit()
796 return {"message": "Activity deleted successfully"}