coming together
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
Customer (Rolodex) API endpoints
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Union
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_, func
|
||||
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
|
||||
@@ -169,36 +172,263 @@ async def get_customer_stats(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[CustomerResponse])
|
||||
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:
|
||||
query = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers))
|
||||
base_query = db.query(Rolodex)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Rolodex.id.contains(search),
|
||||
Rolodex.last.contains(search),
|
||||
Rolodex.first.contains(search),
|
||||
Rolodex.city.contains(search),
|
||||
Rolodex.email.contains(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),
|
||||
)
|
||||
|
||||
customers = query.offset(skip).limit(limit).all()
|
||||
# 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,
|
||||
|
||||
Reference in New Issue
Block a user