This commit is contained in:
HotSwapp
2025-08-18 20:20:04 -05:00
parent 89b2bc0aa2
commit bac8cc4bd5
114 changed files with 30258 additions and 1341 deletions

View File

@@ -15,6 +15,7 @@ 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
@@ -96,6 +97,430 @@ class CustomerResponse(CustomerBase):
@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"),