""" 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 func 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 from app.services.cache import invalidate_search_cache from app.services.customers_search import apply_customer_filters, apply_customer_sorting, prepare_customer_csv_rows from app.services.mailing import build_address_from_rolodex from app.services.query_utils import apply_sorting, paginate_with_total from app.utils.logging import app_logger from app.utils.database import db_transaction router = APIRouter() # Pydantic schemas for request/response from pydantic import BaseModel, EmailStr, Field from pydantic.config import ConfigDict from datetime import date class PhoneCreate(BaseModel): location: Optional[str] = None phone: str class PhoneResponse(BaseModel): id: int location: Optional[str] phone: str model_config = ConfigDict(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] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) @router.get("/phone-book") async def export_phone_book( mode: str = Query("numbers", description="Report mode: numbers | addresses | full"), format: str = Query("csv", description="Output format: csv | html"), group: Optional[str] = Query(None, description="Filter by customer group (exact match)"), groups: Optional[List[str]] = Query(None, description="Filter by multiple groups (repeat param)"), name_prefix: Optional[str] = Query(None, description="Prefix search across first/last name"), sort_by: Optional[str] = Query("name", description="Sort field: id, name, city, email"), sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"), grouping: Optional[str] = Query( "none", description="Grouping: none | letter | group | group_letter" ), page_break: bool = Query( False, description="HTML only: start a new page for each top-level group" ), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Generate phone book reports with filters and downloadable CSV/HTML. Modes: - numbers: name and phone numbers - addresses: name, address, and phone numbers - full: detailed rolodex fields plus phones """ allowed_modes = {"numbers", "addresses", "full"} allowed_formats = {"csv", "html"} allowed_groupings = {"none", "letter", "group", "group_letter"} m = (mode or "").strip().lower() f = (format or "").strip().lower() if m not in allowed_modes: raise HTTPException(status_code=400, detail="Invalid mode. Use one of: numbers, addresses, full") if f not in allowed_formats: raise HTTPException(status_code=400, detail="Invalid format. Use one of: csv, html") gmode = (grouping or "none").strip().lower() if gmode not in allowed_groupings: raise HTTPException(status_code=400, detail="Invalid grouping. Use one of: none, letter, group, group_letter") try: base_query = db.query(Rolodex) # Only group and name_prefix filtering are required per spec base_query = apply_customer_filters( base_query, search=None, group=group, state=None, groups=groups, states=None, name_prefix=name_prefix, ) base_query = apply_customer_sorting(base_query, sort_by=sort_by, sort_dir=sort_dir) customers = base_query.options(joinedload(Rolodex.phone_numbers)).all() def format_phones(entry: Rolodex) -> str: parts: List[str] = [] try: for p in (entry.phone_numbers or []): label = (p.location or "").strip() if label: parts.append(f"{label}: {p.phone}") else: parts.append(p.phone) except Exception: pass return "; ".join([s for s in parts if s]) def display_name(entry: Rolodex) -> str: return build_address_from_rolodex(entry).display_name def first_letter(entry: Rolodex) -> str: base = (entry.last or entry.first or "").strip() if not base: return "#" ch = base[0].upper() return ch if ch.isalpha() else "#" # Apply grouping-specific sort for stable output if gmode == "letter": customers.sort(key=lambda c: (first_letter(c), (c.last or "").lower(), (c.first or "").lower())) elif gmode == "group": customers.sort(key=lambda c: ((c.group or "Ungrouped").lower(), (c.last or "").lower(), (c.first or "").lower())) elif gmode == "group_letter": customers.sort(key=lambda c: ((c.group or "Ungrouped").lower(), first_letter(c), (c.last or "").lower(), (c.first or "").lower())) def build_csv() -> StreamingResponse: output = io.StringIO() writer = csv.writer(output) include_letter_col = gmode in ("letter", "group_letter") if m == "numbers": header = ["Name", "Group"] + (["Letter"] if include_letter_col else []) + ["Phones"] writer.writerow(header) for c in customers: row = [display_name(c), c.group or ""] if include_letter_col: row.append(first_letter(c)) row.append(format_phones(c)) writer.writerow(row) elif m == "addresses": header = [ "Name", "Group" ] + (["Letter"] if include_letter_col else []) + [ "Address 1", "Address 2", "Address 3", "City", "State", "ZIP", "Phones" ] writer.writerow(header) for c in customers: addr = build_address_from_rolodex(c) row = [ addr.display_name, c.group or "", ] if include_letter_col: row.append(first_letter(c)) row += [ c.a1 or "", c.a2 or "", c.a3 or "", c.city or "", c.abrev or "", c.zip or "", format_phones(c), ] writer.writerow(row) else: # full header = [ "ID", "Last", "First", "Middle", "Prefix", "Suffix", "Title", "Group" ] + (["Letter"] if include_letter_col else []) + [ "Address 1", "Address 2", "Address 3", "City", "State", "ZIP", "Email", "Phones", "Legal Status", ] writer.writerow(header) for c in customers: row = [ c.id, c.last or "", c.first or "", c.middle or "", c.prefix or "", c.suffix or "", c.title or "", c.group or "", ] if include_letter_col: row.append(first_letter(c)) row += [ c.a1 or "", c.a2 or "", c.a3 or "", c.city or "", c.abrev or "", c.zip or "", c.email or "", format_phones(c), c.legal_status or "", ] writer.writerow(row) output.seek(0) from datetime import datetime as _dt ts = _dt.now().strftime("%Y%m%d_%H%M%S") filename = f"phone_book_{m}_{ts}.csv" return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}, ) def build_html() -> StreamingResponse: # Minimal, printable HTML def css() -> str: return """ body { font-family: Arial, sans-serif; margin: 16px; } h1 { font-size: 18pt; margin-bottom: 8px; } .meta { color: #666; font-size: 10pt; margin-bottom: 16px; } .entry { margin-bottom: 10px; } .name { font-weight: bold; } .phones, .addr { margin-left: 12px; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 10pt; } th { background: #f5f5f5; text-align: left; } .section { margin-top: 18px; } .section-title { font-size: 14pt; margin: 12px 0; border-bottom: 1px solid #ddd; padding-bottom: 4px; } .subsection-title { font-size: 12pt; margin: 10px 0; color: #333; } @media print { .page-break { page-break-before: always; break-before: page; } } """ title = { "numbers": "Phone Book (Numbers Only)", "addresses": "Phone Book (With Addresses)", "full": "Phone Book (Full Rolodex)", }[m] from datetime import datetime as _dt generated = _dt.now().strftime("%Y-%m-%d %H:%M") def render_entry_block(c: Rolodex) -> str: name = display_name(c) group_text = f" ({c.group})" if c.group else "" phones_html = "".join([f"
{p.location + ': ' if p.location else ''}{p.phone}
" for p in (c.phone_numbers or [])]) addr_html = "" if m == "addresses": addr_lines = build_address_from_rolodex(c).compact_lines(include_name=False) addr_html = "
" + "".join([f"
{line}
" for line in addr_lines]) + "
" return f"
{name}{group_text}
{addr_html}
{phones_html}
" if m in ("numbers", "addresses"): sections: List[str] = [] if gmode == "none": blocks = [render_entry_block(c) for c in customers] html = f""" {title}

{title}

Generated {generated}. Total entries: {len(customers)}.
{''.join(blocks)} """ else: # Build sections according to grouping if gmode == "letter": # Letters A-Z plus '#' letters: List[str] = sorted({first_letter(c) for c in customers}) for idx, letter in enumerate(letters): entries = [c for c in customers if first_letter(c) == letter] if not entries: continue section_class = "section" + (" page-break" if page_break and idx > 0 else "") blocks = [render_entry_block(c) for c in entries] sections.append(f"
Letter: {letter}
{''.join(blocks)}
") elif gmode == "group": group_keys: List[str] = sorted({(c.group or "Ungrouped") for c in customers}, key=lambda s: s.lower()) for idx, gkey in enumerate(group_keys): entries = [c for c in customers if (c.group or "Ungrouped") == gkey] if not entries: continue section_class = "section" + (" page-break" if page_break and idx > 0 else "") blocks = [render_entry_block(c) for c in entries] sections.append(f"
Group: {gkey}
{''.join(blocks)}
") else: # group_letter group_keys: List[str] = sorted({(c.group or "Ungrouped") for c in customers}, key=lambda s: s.lower()) for gidx, gkey in enumerate(group_keys): gentries = [c for c in customers if (c.group or "Ungrouped") == gkey] if not gentries: continue section_class = "section" + (" page-break" if page_break and gidx > 0 else "") subsections: List[str] = [] letters = sorted({first_letter(c) for c in gentries}) for letter in letters: lentries = [c for c in gentries if first_letter(c) == letter] if not lentries: continue blocks = [render_entry_block(c) for c in lentries] subsections.append(f"
Letter: {letter}
{''.join(blocks)}
") sections.append(f"
Group: {gkey}
{''.join(subsections)}
") html = f""" {title}

{title}

Generated {generated}. Total entries: {len(customers)}.
{''.join(sections)} """ else: # Full table variant base_header_cells = [ "ID", "Last", "First", "Middle", "Prefix", "Suffix", "Title", "Group", "Address 1", "Address 2", "Address 3", "City", "State", "ZIP", "Email", "Phones", "Legal Status", ] def render_rows(items: List[Rolodex]) -> str: rows_html: List[str] = [] for c in items: phones = "".join([f"{p.location + ': ' if p.location else ''}{p.phone}" for p in (c.phone_numbers or [])]) cells = [ c.id or "", c.last or "", c.first or "", c.middle or "", c.prefix or "", c.suffix or "", c.title or "", c.group or "", c.a1 or "", c.a2 or "", c.a3 or "", c.city or "", c.abrev or "", c.zip or "", c.email or "", phones, c.legal_status or "", ] rows_html.append("" + "".join([f"{(str(v) if v is not None else '')}" for v in cells]) + "") return "".join(rows_html) if gmode == "none": rows_html = render_rows(customers) html = f""" {title}

{title}

Generated {generated}. Total entries: {len(customers)}.
{''.join([f'' for h in base_header_cells])} {rows_html}
{h}
""" else: sections: List[str] = [] if gmode == "letter": letters: List[str] = sorted({first_letter(c) for c in customers}) for idx, letter in enumerate(letters): entries = [c for c in customers if first_letter(c) == letter] if not entries: continue section_class = "section" + (" page-break" if page_break and idx > 0 else "") rows_html = render_rows(entries) sections.append( f"
Letter: {letter}
" f"{''.join([f'' for h in base_header_cells])}{rows_html}
{h}
" ) elif gmode == "group": group_keys: List[str] = sorted({(c.group or "Ungrouped") for c in customers}, key=lambda s: s.lower()) for idx, gkey in enumerate(group_keys): entries = [c for c in customers if (c.group or "Ungrouped") == gkey] if not entries: continue section_class = "section" + (" page-break" if page_break and idx > 0 else "") rows_html = render_rows(entries) sections.append( f"
Group: {gkey}
" f"{''.join([f'' for h in base_header_cells])}{rows_html}
{h}
" ) else: # group_letter group_keys: List[str] = sorted({(c.group or "Ungrouped") for c in customers}, key=lambda s: s.lower()) for gidx, gkey in enumerate(group_keys): gentries = [c for c in customers if (c.group or "Ungrouped") == gkey] if not gentries: continue section_class = "section" + (" page-break" if page_break and gidx > 0 else "") subsections: List[str] = [] letters = sorted({first_letter(c) for c in gentries}) for letter in letters: lentries = [c for c in gentries if first_letter(c) == letter] if not lentries: continue rows_html = render_rows(lentries) subsections.append( f"
Letter: {letter}
" f"{''.join([f'' for h in base_header_cells])}{rows_html}
{h}
" ) sections.append(f"
Group: {gkey}
{''.join(subsections)}
") html = f""" {title}

{title}

Generated {generated}. Total entries: {len(customers)}.
{''.join(sections)} """ from datetime import datetime as _dt ts = _dt.now().strftime("%Y%m%d_%H%M%S") filename = f"phone_book_{m}_{ts}.html" return StreamingResponse( iter([html]), media_type="text/html", headers={"Content-Disposition": f"attachment; filename=\"{filename}\""}, ) return build_csv() if f == "csv" else build_html() except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error generating phone book: {str(e)}") @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) base_query = apply_customer_filters( base_query, search=search, group=group, state=state, groups=groups, states=states, ) # Apply sorting (whitelisted fields only) base_query = apply_customer_sorting(base_query, sort_by=sort_by, sort_dir=sort_dir) 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) base_query = apply_customer_filters( base_query, search=search, group=group, state=state, groups=groups, states=states, ) base_query = apply_customer_sorting(base_query, sort_by=sort_by, sort_dir=sort_dir) 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) header_row, rows = prepare_customer_csv_rows(customers, fields) writer.writerow(header_row) for row in rows: writer.writerow(row) 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()) with db_transaction(db) as session: session.add(customer) session.flush() session.refresh(customer) try: await invalidate_search_cache() except Exception as e: app_logger.warning(f"Failed to invalidate search cache: {str(e)}") 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) with db_transaction(db) as session: session.flush() session.refresh(customer) try: await invalidate_search_cache() except Exception as e: app_logger.warning(f"Failed to invalidate search cache: {str(e)}") 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" ) with db_transaction(db) as session: session.delete(customer) try: await invalidate_search_cache() except Exception as e: app_logger.warning(f"Failed to invalidate search cache: {str(e)}") return {"message": "Customer deleted successfully"} class PaginatedPhonesResponse(BaseModel): items: List[PhoneResponse] total: int @router.get("/{customer_id}/phones", response_model=Union[List[PhoneResponse], PaginatedPhonesResponse]) async def get_customer_phones( customer_id: str, skip: int = Query(0, ge=0, description="Offset for pagination"), limit: int = Query(100, ge=1, le=1000, description="Page size"), sort_by: Optional[str] = Query("location", description="Sort by: location, phone"), 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) ): """Get customer phone numbers with optional sorting/pagination""" 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" ) query = db.query(Phone).filter(Phone.rolodex_id == customer_id) query = apply_sorting( query, sort_by, sort_dir, allowed={ "location": [Phone.location, Phone.phone], "phone": [Phone.phone], }, ) phones, total = paginate_with_total(query, skip, limit, include_total) if include_total: return {"items": phones, "total": total or 0} 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"}