Files
delphi-database-v2/app/reporting.py
HotSwapp aeb0be6982 feat(reports): add Envelope, Phone Book (address+phone) and Rolodex Info reports
- PDF builders in app/reporting.py (envelope, phone+address, rolodex info)
- Endpoints in app/main.py with auth, filtering, logging, Content-Disposition
- New HTML template report_phone_book_address.html
- Rolodex bulk actions updated with buttons/links
- JS helper to submit selections to alternate endpoints

Tested via docker compose build/up and health check.
2025-10-07 17:50:03 -05:00

350 lines
12 KiB
Python

"""
Reporting utilities for generating PDF documents.
Provides PDF builders used by report endpoints (phone book, payments detailed).
Uses fpdf2 to generate simple tabular PDFs with automatic pagination.
"""
from __future__ import annotations
from datetime import date
from io import BytesIO
from typing import Iterable, List, Dict, Any, Tuple
from fpdf import FPDF
import structlog
# Local imports are type-only to avoid circular import costs at import time
from .models import Client, Payment
logger = structlog.get_logger(__name__)
class SimplePDF(FPDF):
"""Small helper subclass to set defaults and provide header/footer hooks."""
def __init__(self, title: str):
super().__init__(orientation="P", unit="mm", format="Letter")
self.title = title
self.set_auto_page_break(auto=True, margin=15)
self.set_margins(left=12, top=12, right=12)
def header(self): # type: ignore[override]
self.set_font("helvetica", "B", 12)
self.cell(0, 8, self.title, ln=1, align="L")
self.ln(2)
def footer(self): # type: ignore[override]
self.set_y(-12)
self.set_font("helvetica", size=8)
self.set_text_color(120)
self.cell(0, 8, f"Page {self.page_no()}", align="R")
def _output_pdf_bytes(pdf: FPDF) -> bytes:
"""Return the PDF content as bytes.
fpdf2's output(dest='S') returns a str; encode to latin-1 per fpdf guidance.
"""
content_str = pdf.output(dest="S") # type: ignore[no-untyped-call]
if isinstance(content_str, bytes):
return content_str
return content_str.encode("latin-1")
def build_phone_book_pdf(clients: List[Client]) -> bytes:
"""Build a Phone Book PDF from a list of `Client` records with phones."""
logger.info("pdf_phone_book_start", count=len(clients))
pdf = SimplePDF(title="Phone Book")
pdf.add_page()
# Table header
pdf.set_font("helvetica", "B", 10)
headers = ["Name", "Company", "Phone Type", "Phone Number"]
widths = [55, 55, 35, 45]
for h, w in zip(headers, widths):
pdf.cell(w, 8, h, border=1)
pdf.ln(8)
pdf.set_font("helvetica", size=10)
for client in clients:
rows: List[Tuple[str, str, str, str]] = []
name = f"{client.last_name or ''}, {client.first_name or ''}".strip(", ")
company = client.company or ""
if getattr(client, "phones", None):
for p in client.phones: # type: ignore[attr-defined]
rows.append((name, company, p.phone_type or "", p.phone_number or ""))
else:
rows.append((name, company, "", ""))
for c0, c1, c2, c3 in rows:
pdf.cell(widths[0], 7, c0[:35], border=1)
pdf.cell(widths[1], 7, c1[:35], border=1)
pdf.cell(widths[2], 7, c2[:18], border=1)
pdf.cell(widths[3], 7, c3[:24], border=1)
pdf.ln(7)
logger.info("pdf_phone_book_done", pages=pdf.page_no())
return _output_pdf_bytes(pdf)
def build_payments_detailed_pdf(payments: List[Payment]) -> bytes:
"""Build a Payments - Detailed PDF grouped by deposit (payment) date.
Groups by date portion of `payment_date`. Includes per-day totals and overall total.
"""
logger.info("pdf_payments_detailed_start", count=len(payments))
# Group payments by date
grouped: Dict[date, List[Payment]] = {}
for p in payments:
d = p.payment_date.date() if p.payment_date else None
if d is None:
# Place undated at epoch-ish bucket None-equivalent: skip grouping
continue
grouped.setdefault(d, []).append(p)
dates_sorted = sorted(grouped.keys())
overall_total = sum((p.amount or 0.0) for p in payments)
pdf = SimplePDF(title="Payments - Detailed")
pdf.add_page()
pdf.set_font("helvetica", size=10)
pdf.cell(0, 6, f"Total Amount: ${overall_total:,.2f}", ln=1)
pdf.ln(1)
for d in dates_sorted:
day_items = grouped[d]
day_total = sum((p.amount or 0.0) for p in day_items)
# Section header per date
pdf.set_font("helvetica", "B", 11)
pdf.cell(0, 7, f"Deposit Date: {d.isoformat()} — Total: ${day_total:,.2f}", ln=1)
# Table header
pdf.set_font("helvetica", "B", 10)
headers = ["File #", "Client", "Type", "Description", "Amount"]
widths = [28, 50, 18, 80, 18]
for h, w in zip(headers, widths):
pdf.cell(w, 7, h, border=1)
pdf.ln(7)
pdf.set_font("helvetica", size=10)
for p in day_items:
file_no = p.case.file_no if p.case else ""
client = ""
if p.case and p.case.client:
client = f"{p.case.client.last_name or ''}, {p.case.client.first_name or ''}".strip(", ")
ptype = p.payment_type or ""
desc = (p.description or "").replace("\n", " ")
amt = f"${(p.amount or 0.0):,.2f}"
# Row cells
pdf.cell(widths[0], 6, file_no[:14], border=1)
pdf.cell(widths[1], 6, client[:28], border=1)
pdf.cell(widths[2], 6, ptype[:8], border=1)
# Description as MultiCell: compute remaining width before amount
x_before = pdf.get_x()
y_before = pdf.get_y()
pdf.multi_cell(widths[3], 6, desc[:300], border=1)
# Move to amount cell position (right side) aligning with the top of description row
x_after = x_before + widths[3] + widths[0] + widths[1] + widths[2]
# Reset cursor to top of the description cell's first line row to draw amount
pdf.set_xy(x_after, y_before)
pdf.cell(widths[4], 6, amt, border=1, align="R")
pdf.ln(0) # continue after multicell handled line advance
pdf.ln(3)
logger.info("pdf_payments_detailed_done", pages=pdf.page_no())
return _output_pdf_bytes(pdf)
# ------------------------------
# Additional PDF Builders
# ------------------------------
def _format_client_name(client: Client) -> str:
last = client.last_name or ""
first = client.first_name or ""
name = f"{last}, {first}".strip(", ")
return name or (client.company or "")
def _format_city_state_zip(client: Client) -> str:
parts: list[str] = []
if client.city:
parts.append(client.city)
state_zip = " ".join([p for p in [(client.state or ""), (client.zip_code or "")] if p])
if state_zip:
if parts:
parts[-1] = f"{parts[-1]},"
parts.append(state_zip)
return " ".join(parts)
def build_envelope_pdf(clients: List[Client]) -> bytes:
"""Build an Envelope PDF with mailing blocks per client.
Layout uses a simple grid to place multiple #10 envelope-style address
blocks per Letter page. Each block includes:
- Name (Last, First)
- Company (if present)
- Address line
- City, ST ZIP
"""
logger.info("pdf_envelope_start", count=len(clients))
pdf = SimplePDF(title="Envelope Blocks")
pdf.add_page()
# Grid parameters
usable_width = pdf.w - pdf.l_margin - pdf.r_margin
usable_height = pdf.h - pdf.t_margin - pdf.b_margin
cols = 2
col_w = usable_width / cols
row_h = 45 # mm per block
rows = max(1, int(usable_height // row_h))
pdf.set_font("helvetica", size=11)
col = 0
row = 0
for idx, c in enumerate(clients):
if row >= rows:
# next page
pdf.add_page()
col = 0
row = 0
x = pdf.l_margin + (col * col_w) + 6 # slight inner padding
y = pdf.t_margin + (row * row_h) + 8
# Draw block contents
pdf.set_xy(x, y)
name_line = _format_client_name(c)
if name_line:
pdf.cell(col_w - 12, 6, name_line, ln=1)
if c.company:
pdf.set_x(x)
pdf.cell(col_w - 12, 6, c.company[:48], ln=1)
if c.address:
pdf.set_x(x)
pdf.cell(col_w - 12, 6, c.address[:48], ln=1)
city_state_zip = _format_city_state_zip(c)
if city_state_zip:
pdf.set_x(x)
pdf.cell(col_w - 12, 6, city_state_zip[:48], ln=1)
# Advance grid position
col += 1
if col >= cols:
col = 0
row += 1
logger.info("pdf_envelope_done", pages=pdf.page_no())
return _output_pdf_bytes(pdf)
def build_phone_book_address_pdf(clients: List[Client]) -> bytes:
"""Build a Phone Book (Address + Phone) PDF.
Columns: Name, Company, Address, City, State, ZIP, Phone
Multiple phone numbers yield multiple rows per client.
"""
logger.info("pdf_phone_book_addr_start", count=len(clients))
pdf = SimplePDF(title="Phone Book — Address + Phone")
pdf.add_page()
headers = ["Name", "Company", "Address", "City", "State", "ZIP", "Phone"]
widths = [40, 40, 55, 28, 12, 18, 30]
pdf.set_font("helvetica", "B", 9)
for h, w in zip(headers, widths):
pdf.cell(w, 7, h, border=1)
pdf.ln(7)
pdf.set_font("helvetica", size=9)
for c in clients:
name = _format_client_name(c)
phones = getattr(c, "phones", None) or [] # type: ignore[attr-defined]
if phones:
for p in phones:
pdf.cell(widths[0], 6, (name or "")[:24], border=1)
pdf.cell(widths[1], 6, (c.company or "")[:24], border=1)
pdf.cell(widths[2], 6, (c.address or "")[:32], border=1)
pdf.cell(widths[3], 6, (c.city or "")[:14], border=1)
pdf.cell(widths[4], 6, (c.state or "")[:4], border=1)
pdf.cell(widths[5], 6, (c.zip_code or "")[:10], border=1)
pdf.cell(widths[6], 6, (getattr(p, "phone_number", "") or "")[:18], border=1)
pdf.ln(6)
else:
pdf.cell(widths[0], 6, (name or "")[:24], border=1)
pdf.cell(widths[1], 6, (c.company or "")[:24], border=1)
pdf.cell(widths[2], 6, (c.address or "")[:32], border=1)
pdf.cell(widths[3], 6, (c.city or "")[:14], border=1)
pdf.cell(widths[4], 6, (c.state or "")[:4], border=1)
pdf.cell(widths[5], 6, (c.zip_code or "")[:10], border=1)
pdf.cell(widths[6], 6, "", border=1)
pdf.ln(6)
logger.info("pdf_phone_book_addr_done", pages=pdf.page_no())
return _output_pdf_bytes(pdf)
def build_rolodex_info_pdf(clients: List[Client]) -> bytes:
"""Build a Rolodex Info PDF with stacked info blocks per client."""
logger.info("pdf_rolodex_info_start", count=len(clients))
pdf = SimplePDF(title="Rolodex Info")
pdf.add_page()
pdf.set_font("helvetica", size=11)
for idx, c in enumerate(clients):
# Section header
pdf.set_font("helvetica", "B", 12)
pdf.cell(0, 7, _format_client_name(c) or "(No Name)", ln=1)
pdf.set_font("helvetica", size=10)
# Company
if c.company:
pdf.cell(0, 6, f"Company: {c.company}", ln=1)
# Address lines
if c.address:
pdf.cell(0, 6, f"Address: {c.address}", ln=1)
city_state_zip = _format_city_state_zip(c)
if city_state_zip:
pdf.cell(0, 6, f"City/State/ZIP: {city_state_zip}", ln=1)
# Legacy Id
if c.rolodex_id:
pdf.cell(0, 6, f"Legacy ID: {c.rolodex_id}", ln=1)
# Phones
phones = getattr(c, "phones", None) or [] # type: ignore[attr-defined]
if phones:
for p in phones:
ptype = (getattr(p, "phone_type", "") or "").strip()
pnum = (getattr(p, "phone_number", "") or "").strip()
label = f"{ptype}: {pnum}" if ptype else pnum
pdf.cell(0, 6, f"Phone: {label}", ln=1)
# Divider
pdf.ln(2)
pdf.set_draw_color(200)
x1 = pdf.l_margin
x2 = pdf.w - pdf.r_margin
y = pdf.get_y()
pdf.line(x1, y, x2, y)
pdf.ln(3)
logger.info("pdf_rolodex_info_done", pages=pdf.page_no())
return _output_pdf_bytes(pdf)