940 lines
36 KiB
Python
940 lines
36 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 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" <span class=\"group\">({c.group})</span>" if c.group else ""
|
|
phones_html = "".join([f"<div>{p.location + ': ' if p.location else ''}{p.phone}</div>" 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 = "<div class=\"addr\">" + "".join([f"<div>{line}</div>" for line in addr_lines]) + "</div>"
|
|
return f"<div class=\"entry\"><div class=\"name\">{name}{group_text}</div>{addr_html}<div class=\"phones\">{phones_html}</div></div>"
|
|
|
|
if m in ("numbers", "addresses"):
|
|
sections: List[str] = []
|
|
|
|
if gmode == "none":
|
|
blocks = [render_entry_block(c) for c in customers]
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<title>{title}</title>
|
|
<style>{css()}</style>
|
|
<meta name=\"generator\" content=\"delphi\" />
|
|
<meta name=\"created\" content=\"{generated}\" />
|
|
</head>
|
|
<body>
|
|
<h1>{title}</h1>
|
|
<div class=\"meta\">Generated {generated}. Total entries: {len(customers)}.</div>
|
|
{''.join(blocks)}
|
|
</body>
|
|
</html>
|
|
"""
|
|
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"<div class=\"{section_class}\"><div class=\"section-title\">Letter: {letter}</div>{''.join(blocks)}</div>")
|
|
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"<div class=\"{section_class}\"><div class=\"section-title\">Group: {gkey}</div>{''.join(blocks)}</div>")
|
|
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"<div class=\"subsection\"><div class=\"subsection-title\">Letter: {letter}</div>{''.join(blocks)}</div>")
|
|
sections.append(f"<div class=\"{section_class}\"><div class=\"section-title\">Group: {gkey}</div>{''.join(subsections)}</div>")
|
|
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<title>{title}</title>
|
|
<style>{css()}</style>
|
|
<meta name=\"generator\" content=\"delphi\" />
|
|
<meta name=\"created\" content=\"{generated}\" />
|
|
</head>
|
|
<body>
|
|
<h1>{title}</h1>
|
|
<div class=\"meta\">Generated {generated}. Total entries: {len(customers)}.</div>
|
|
{''.join(sections)}
|
|
</body>
|
|
</html>
|
|
"""
|
|
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("<tr>" + "".join([f"<td>{(str(v) if v is not None else '')}</td>" for v in cells]) + "</tr>")
|
|
return "".join(rows_html)
|
|
|
|
if gmode == "none":
|
|
rows_html = render_rows(customers)
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<title>{title}</title>
|
|
<style>{css()}</style>
|
|
<meta name=\"generator\" content=\"delphi\" />
|
|
<meta name=\"created\" content=\"{generated}\" />
|
|
</head>
|
|
<body>
|
|
<h1>{title}</h1>
|
|
<div class=\"meta\">Generated {generated}. Total entries: {len(customers)}.</div>
|
|
<table>
|
|
<thead><tr>{''.join([f'<th>{h}</th>' for h in base_header_cells])}</tr></thead>
|
|
<tbody>
|
|
{rows_html}
|
|
</tbody>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
"""
|
|
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"<div class=\"{section_class}\"><div class=\"section-title\">Letter: {letter}</div>"
|
|
f"<table><thead><tr>{''.join([f'<th>{h}</th>' for h in base_header_cells])}</tr></thead><tbody>{rows_html}</tbody></table></div>"
|
|
)
|
|
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"<div class=\"{section_class}\"><div class=\"section-title\">Group: {gkey}</div>"
|
|
f"<table><thead><tr>{''.join([f'<th>{h}</th>' for h in base_header_cells])}</tr></thead><tbody>{rows_html}</tbody></table></div>"
|
|
)
|
|
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"<div class=\"subsection\"><div class=\"subsection-title\">Letter: {letter}</div>"
|
|
f"<table><thead><tr>{''.join([f'<th>{h}</th>' for h in base_header_cells])}</tr></thead><tbody>{rows_html}</tbody></table></div>"
|
|
)
|
|
sections.append(f"<div class=\"{section_class}\"><div class=\"section-title\">Group: {gkey}</div>{''.join(subsections)}</div>")
|
|
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<title>{title}</title>
|
|
<style>{css()}</style>
|
|
<meta name=\"generator\" content=\"delphi\" />
|
|
<meta name=\"created\" content=\"{generated}\" />
|
|
</head>
|
|
<body>
|
|
<h1>{title}</h1>
|
|
<div class=\"meta\">Generated {generated}. Total entries: {len(customers)}.</div>
|
|
{''.join(sections)}
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
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"} |