""" Customer (Rolodex) API endpoints """ from typing import List, Optional, Union from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session, joinedload from sqlalchemy import or_, and_, func, asc, desc from fastapi.responses import StreamingResponse import csv import io from app.database.base import get_db from app.models.rolodex import Rolodex, Phone from app.models.user import User from app.auth.security import get_current_user router = APIRouter() # Pydantic schemas for request/response from pydantic import BaseModel, EmailStr from datetime import date class PhoneCreate(BaseModel): location: Optional[str] = None phone: str class PhoneResponse(BaseModel): id: int location: Optional[str] phone: str class Config: from_attributes = True class CustomerBase(BaseModel): id: str last: str first: Optional[str] = None middle: Optional[str] = None prefix: Optional[str] = None suffix: Optional[str] = None title: Optional[str] = None group: Optional[str] = None a1: Optional[str] = None a2: Optional[str] = None a3: Optional[str] = None city: Optional[str] = None abrev: Optional[str] = None zip: Optional[str] = None email: Optional[EmailStr] = None dob: Optional[date] = None ss_number: Optional[str] = None legal_status: Optional[str] = None memo: Optional[str] = None class CustomerCreate(CustomerBase): pass class CustomerUpdate(BaseModel): last: Optional[str] = None first: Optional[str] = None middle: Optional[str] = None prefix: Optional[str] = None suffix: Optional[str] = None title: Optional[str] = None group: Optional[str] = None a1: Optional[str] = None a2: Optional[str] = None a3: Optional[str] = None city: Optional[str] = None abrev: Optional[str] = None zip: Optional[str] = None email: Optional[EmailStr] = None dob: Optional[date] = None ss_number: Optional[str] = None legal_status: Optional[str] = None memo: Optional[str] = None class CustomerResponse(CustomerBase): phone_numbers: List[PhoneResponse] = [] class Config: from_attributes = True @router.get("/search/phone") async def search_by_phone( phone: str = Query(..., description="Phone number to search for"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Search customers by phone number (legacy phone search feature)""" phones = db.query(Phone).join(Rolodex).filter( Phone.phone.contains(phone) ).options(joinedload(Phone.rolodex_entry)).all() results = [] for phone_record in phones: results.append({ "phone": phone_record.phone, "location": phone_record.location, "customer": { "id": phone_record.rolodex_entry.id, "name": f"{phone_record.rolodex_entry.first or ''} {phone_record.rolodex_entry.last}".strip(), "city": phone_record.rolodex_entry.city, "state": phone_record.rolodex_entry.abrev } }) return results @router.get("/groups") async def get_customer_groups( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get list of customer groups for filtering""" groups = db.query(Rolodex.group).filter( Rolodex.group.isnot(None), Rolodex.group != "" ).distinct().all() return [{"group": group[0]} for group in groups if group[0]] @router.get("/states") async def get_states( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get list of states used in the database""" states = db.query(Rolodex.abrev).filter( Rolodex.abrev.isnot(None), Rolodex.abrev != "" ).distinct().all() return [{"state": state[0]} for state in states if state[0]] @router.get("/stats") async def get_customer_stats( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get customer database statistics""" total_customers = db.query(Rolodex).count() total_phones = db.query(Phone).count() customers_with_email = db.query(Rolodex).filter( Rolodex.email.isnot(None), Rolodex.email != "" ).count() # Group breakdown group_stats = db.query(Rolodex.group, func.count(Rolodex.id)).filter( Rolodex.group.isnot(None), Rolodex.group != "" ).group_by(Rolodex.group).all() return { "total_customers": total_customers, "total_phone_numbers": total_phones, "customers_with_email": customers_with_email, "group_breakdown": [{"group": group, "count": count} for group, count in group_stats] } class PaginatedCustomersResponse(BaseModel): items: List[CustomerResponse] total: int @router.get("/", response_model=Union[List[CustomerResponse], PaginatedCustomersResponse]) async def list_customers( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), search: Optional[str] = Query(None), group: Optional[str] = Query(None, description="Filter by customer group (exact match)"), state: Optional[str] = Query(None, description="Filter by state abbreviation (exact match)"), groups: Optional[List[str]] = Query(None, description="Filter by multiple groups (repeat param)"), states: Optional[List[str]] = Query(None, description="Filter by multiple states (repeat param)"), sort_by: Optional[str] = Query("id", description="Sort field: id, name, city, email"), sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"), include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """List customers with pagination and search""" try: base_query = db.query(Rolodex) if search: s = (search or "").strip() s_lower = s.lower() tokens = [t for t in s_lower.split() if t] # Basic contains search on several fields (case-insensitive) contains_any = or_( func.lower(Rolodex.id).contains(s_lower), func.lower(Rolodex.last).contains(s_lower), func.lower(Rolodex.first).contains(s_lower), func.lower(Rolodex.middle).contains(s_lower), func.lower(Rolodex.city).contains(s_lower), func.lower(Rolodex.email).contains(s_lower), ) # Multi-token name support: every token must match either first, middle, or last name_tokens = [ or_( func.lower(Rolodex.first).contains(tok), func.lower(Rolodex.middle).contains(tok), func.lower(Rolodex.last).contains(tok), ) for tok in tokens ] combined = contains_any if not name_tokens else or_(contains_any, and_(*name_tokens)) # Comma pattern: "Last, First" last_first_filter = None if "," in s_lower: last_part, first_part = [p.strip() for p in s_lower.split(",", 1)] if last_part and first_part: last_first_filter = and_( func.lower(Rolodex.last).contains(last_part), func.lower(Rolodex.first).contains(first_part), ) elif last_part: last_first_filter = func.lower(Rolodex.last).contains(last_part) final_filter = or_(combined, last_first_filter) if last_first_filter is not None else combined base_query = base_query.filter(final_filter) # Apply group/state filters (support single and multi-select) effective_groups = [g for g in (groups or []) if g] or ([group] if group else []) if effective_groups: base_query = base_query.filter(Rolodex.group.in_(effective_groups)) effective_states = [s for s in (states or []) if s] or ([state] if state else []) if effective_states: base_query = base_query.filter(Rolodex.abrev.in_(effective_states)) # Apply sorting (whitelisted fields only) normalized_sort_by = (sort_by or "id").lower() normalized_sort_dir = (sort_dir or "asc").lower() is_desc = normalized_sort_dir == "desc" order_columns = [] if normalized_sort_by == "id": order_columns = [Rolodex.id] elif normalized_sort_by == "name": # Sort by last, then first order_columns = [Rolodex.last, Rolodex.first] elif normalized_sort_by == "city": # Sort by city, then state abbreviation order_columns = [Rolodex.city, Rolodex.abrev] elif normalized_sort_by == "email": order_columns = [Rolodex.email] else: # Fallback to id to avoid arbitrary column injection order_columns = [Rolodex.id] # Case-insensitive ordering where applicable, preserving None ordering default ordered = [] for col in order_columns: # Use lower() for string-like cols; SQLAlchemy will handle non-string safely enough for SQLite/Postgres expr = func.lower(col) if col.type.python_type in (str,) else col # type: ignore[attr-defined] ordered.append(desc(expr) if is_desc else asc(expr)) if ordered: base_query = base_query.order_by(*ordered) customers = base_query.options(joinedload(Rolodex.phone_numbers)).offset(skip).limit(limit).all() if include_total: total = base_query.count() return {"items": customers, "total": total} return customers except Exception as e: raise HTTPException(status_code=500, detail=f"Error loading customers: {str(e)}") @router.get("/export") async def export_customers( # Optional pagination for exporting only current page; omit to export all skip: Optional[int] = Query(None, ge=0), limit: Optional[int] = Query(None, ge=1, le=1000000), search: Optional[str] = Query(None), group: Optional[str] = Query(None, description="Filter by customer group (exact match)"), state: Optional[str] = Query(None, description="Filter by state abbreviation (exact match)"), groups: Optional[List[str]] = Query(None, description="Filter by multiple groups (repeat param)"), states: Optional[List[str]] = Query(None, description="Filter by multiple states (repeat param)"), sort_by: Optional[str] = Query("id", description="Sort field: id, name, city, email"), sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"), fields: Optional[List[str]] = Query(None, description="CSV fields to include: id,name,group,city,state,phone,email"), export_all: bool = Query(False, description="When true, ignore skip/limit and export all matches"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Export customers as CSV respecting search, filters, and sorting. If skip/limit provided, exports that slice; otherwise exports all matches. """ try: base_query = db.query(Rolodex) if search: s = (search or "").strip() s_lower = s.lower() tokens = [t for t in s_lower.split() if t] contains_any = or_( func.lower(Rolodex.id).contains(s_lower), func.lower(Rolodex.last).contains(s_lower), func.lower(Rolodex.first).contains(s_lower), func.lower(Rolodex.middle).contains(s_lower), func.lower(Rolodex.city).contains(s_lower), func.lower(Rolodex.email).contains(s_lower), ) name_tokens = [ or_( func.lower(Rolodex.first).contains(tok), func.lower(Rolodex.middle).contains(tok), func.lower(Rolodex.last).contains(tok), ) for tok in tokens ] combined = contains_any if not name_tokens else or_(contains_any, and_(*name_tokens)) last_first_filter = None if "," in s_lower: last_part, first_part = [p.strip() for p in s_lower.split(",", 1)] if last_part and first_part: last_first_filter = and_( func.lower(Rolodex.last).contains(last_part), func.lower(Rolodex.first).contains(first_part), ) elif last_part: last_first_filter = func.lower(Rolodex.last).contains(last_part) final_filter = or_(combined, last_first_filter) if last_first_filter is not None else combined base_query = base_query.filter(final_filter) effective_groups = [g for g in (groups or []) if g] or ([group] if group else []) if effective_groups: base_query = base_query.filter(Rolodex.group.in_(effective_groups)) effective_states = [s for s in (states or []) if s] or ([state] if state else []) if effective_states: base_query = base_query.filter(Rolodex.abrev.in_(effective_states)) normalized_sort_by = (sort_by or "id").lower() normalized_sort_dir = (sort_dir or "asc").lower() is_desc = normalized_sort_dir == "desc" order_columns = [] if normalized_sort_by == "id": order_columns = [Rolodex.id] elif normalized_sort_by == "name": order_columns = [Rolodex.last, Rolodex.first] elif normalized_sort_by == "city": order_columns = [Rolodex.city, Rolodex.abrev] elif normalized_sort_by == "email": order_columns = [Rolodex.email] else: order_columns = [Rolodex.id] ordered = [] for col in order_columns: try: expr = func.lower(col) if col.type.python_type in (str,) else col # type: ignore[attr-defined] except Exception: expr = col ordered.append(desc(expr) if is_desc else asc(expr)) if ordered: base_query = base_query.order_by(*ordered) if not export_all: if skip is not None: base_query = base_query.offset(skip) if limit is not None: base_query = base_query.limit(limit) customers = base_query.options(joinedload(Rolodex.phone_numbers)).all() # Prepare CSV output = io.StringIO() writer = csv.writer(output) allowed_fields_in_order = ["id", "name", "group", "city", "state", "phone", "email"] header_names = { "id": "Customer ID", "name": "Name", "group": "Group", "city": "City", "state": "State", "phone": "Primary Phone", "email": "Email", } requested = [f.lower() for f in (fields or []) if isinstance(f, str)] selected_fields = [f for f in allowed_fields_in_order if f in requested] if requested else allowed_fields_in_order if not selected_fields: selected_fields = allowed_fields_in_order writer.writerow([header_names[f] for f in selected_fields]) for c in customers: full_name = f"{(c.first or '').strip()} {(c.last or '').strip()}".strip() primary_phone = "" try: if c.phone_numbers: primary_phone = c.phone_numbers[0].phone or "" except Exception: primary_phone = "" row_map = { "id": c.id, "name": full_name, "group": c.group or "", "city": c.city or "", "state": c.abrev or "", "phone": primary_phone, "email": c.email or "", } writer.writerow([row_map[f] for f in selected_fields]) output.seek(0) filename = "customers_export.csv" return StreamingResponse( output, media_type="text/csv", headers={ "Content-Disposition": f"attachment; filename={filename}" }, ) except Exception as e: raise HTTPException(status_code=500, detail=f"Error exporting customers: {str(e)}") @router.get("/{customer_id}", response_model=CustomerResponse) async def get_customer( customer_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get specific customer by ID""" customer = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers)).filter( Rolodex.id == customer_id ).first() if not customer: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found" ) return customer @router.post("/", response_model=CustomerResponse) async def create_customer( customer_data: CustomerCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Create new customer""" # Check if ID already exists existing = db.query(Rolodex).filter(Rolodex.id == customer_data.id).first() if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Customer ID already exists" ) customer = Rolodex(**customer_data.model_dump()) db.add(customer) db.commit() db.refresh(customer) return customer @router.put("/{customer_id}", response_model=CustomerResponse) async def update_customer( customer_id: str, customer_data: CustomerUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update customer""" customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first() if not customer: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found" ) # Update fields for field, value in customer_data.model_dump(exclude_unset=True).items(): setattr(customer, field, value) db.commit() db.refresh(customer) return customer @router.delete("/{customer_id}") async def delete_customer( customer_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete customer""" customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first() if not customer: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found" ) db.delete(customer) db.commit() return {"message": "Customer deleted successfully"} @router.get("/{customer_id}/phones", response_model=List[PhoneResponse]) async def get_customer_phones( customer_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get customer phone numbers""" customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first() if not customer: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found" ) phones = db.query(Phone).filter(Phone.rolodex_id == customer_id).all() return phones @router.post("/{customer_id}/phones", response_model=PhoneResponse) async def add_customer_phone( customer_id: str, phone_data: PhoneCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Add phone number to customer""" customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first() if not customer: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found" ) phone = Phone( rolodex_id=customer_id, location=phone_data.location, phone=phone_data.phone ) db.add(phone) db.commit() db.refresh(phone) return phone @router.put("/{customer_id}/phones/{phone_id}", response_model=PhoneResponse) async def update_customer_phone( customer_id: str, phone_id: int, phone_data: PhoneCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update customer phone number""" phone = db.query(Phone).filter( Phone.id == phone_id, Phone.rolodex_id == customer_id ).first() if not phone: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found" ) phone.location = phone_data.location phone.phone = phone_data.phone db.commit() db.refresh(phone) return phone @router.delete("/{customer_id}/phones/{phone_id}") async def delete_customer_phone( customer_id: str, phone_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete customer phone number""" phone = db.query(Phone).filter( Phone.id == phone_id, Phone.rolodex_id == customer_id ).first() if not phone: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found" ) db.delete(phone) db.commit() return {"message": "Phone number deleted successfully"}