Files
delphi-database/app/api/customers.py
2025-08-13 18:53:35 -05:00

621 lines
21 KiB
Python

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