From aeb0be698239a796cbad3ecefb012df419d13f38 Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:50:03 -0500 Subject: [PATCH] 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. --- app/__pycache__/main.cpython-313.pyc | Bin 116647 -> 127354 bytes app/__pycache__/reporting.cpython-313.pyc | Bin 9513 -> 18884 bytes app/main.py | 224 ++++++++++++++++++- app/reporting.py | 183 +++++++++++++++ app/templates/report_phone_book_address.html | 74 ++++++ app/templates/rolodex.html | 9 + static/js/custom.js | 19 ++ 7 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 app/templates/report_phone_book_address.html diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index e89d1dea15f5597de93415fccf3747d2e728d133..3874ad4fa724471ed883504eabf967c5225732d7 100644 GIT binary patch delta 24092 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 ZNG!HTB7iO|o5$KCzh+}~x}~6P{|g)q3l#tW delta 18078 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 diff --git a/app/__pycache__/reporting.cpython-313.pyc b/app/__pycache__/reporting.cpython-313.pyc index f12146adc3da1e3dd9cc44609bb116b84b2a7ee3..f818510c11c892296df9d5a84724d7636b7a1025 100644 GIT binary patch delta 7475 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 Scj!jC_P5`rkI=Qm%zpzSVi!pO delta 200 zcmX>ynQ^5T-)CN4E(Rc2G399nulPhh3C1H6)mJF;2lGfVWU&;92MYxAf>{#5g28-X zmSj4+rqE_3##_>inw$U11TZpA+w3Q|kdblOWHa^HWN5QtgV4 Z0lADoTpT*t+cS{os~4j?Bcl>nDF9|LG{yh` diff --git a/app/main.py b/app/main.py index 72a1b8f..af6b59a 100644 --- a/app/main.py +++ b/app/main.py @@ -31,7 +31,13 @@ from structlog import contextvars as structlog_contextvars from .database import create_tables, get_db, get_database_url from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog from .auth import authenticate_user, get_current_user_from_session -from .reporting import build_phone_book_pdf, build_payments_detailed_pdf +from .reporting import ( + build_phone_book_pdf, + build_payments_detailed_pdf, + build_envelope_pdf, + build_phone_book_address_pdf, + build_rolodex_info_pdf, +) from .logging_config import setup_logging from .schemas import ( ClientOut, @@ -2420,6 +2426,222 @@ async def payments_detailed_report( }, ) + +# ------------------------------ +# Reports: Phone Book (Address + Phone) +# ------------------------------ + +@app.post("/reports/phone-book-address") +async def phone_book_address_post(request: Request): + """Accept selected client IDs from forms and redirect to GET for rendering.""" + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + form = await request.form() + client_ids = form.getlist("client_ids") + if not client_ids: + return RedirectResponse(url="/rolodex", status_code=302) + + ids_param = "&".join([f"client_ids={cid}" for cid in client_ids]) + return RedirectResponse(url=f"/reports/phone-book-address?{ids_param}", status_code=302) + + +@app.get("/reports/phone-book-address") +async def phone_book_address_report( + request: Request, + client_ids: List[int] | None = Query(None), + q: str | None = Query(None, description="Filter by name/company"), + phone: str | None = Query(None, description="Phone contains"), + format: str | None = Query(None, description="csv or pdf for export"), + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + query = db.query(Client).options(joinedload(Client.phones)) + if client_ids: + query = query.filter(Client.id.in_(client_ids)) + else: + if q: + like = f"%{q}%" + query = query.filter( + or_(Client.first_name.ilike(like), Client.last_name.ilike(like), Client.company.ilike(like)) + ) + if phone: + query = query.filter(Client.phones.any(Phone.phone_number.ilike(f"%{phone}%"))) + + clients = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()).all() + + if format == "csv": + # Build CSV output + output = StringIO() + writer = csv.writer(output) + writer.writerow(["Last", "First", "Company", "Address", "City", "State", "ZIP", "Phone Type", "Phone Number"]) + for c in clients: + if c.phones: + for p in c.phones: + writer.writerow([ + c.last_name or "", + c.first_name or "", + c.company or "", + c.address or "", + c.city or "", + c.state or "", + c.zip_code or "", + p.phone_type or "", + p.phone_number or "", + ]) + else: + writer.writerow([ + c.last_name or "", + c.first_name or "", + c.company or "", + c.address or "", + c.city or "", + c.state or "", + c.zip_code or "", + "", + "", + ]) + csv_bytes = output.getvalue().encode("utf-8") + return Response( + content=csv_bytes, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=phone_book_address.csv"}, + ) + + if format == "pdf": + pdf_bytes = build_phone_book_address_pdf(clients) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=phone_book_address.pdf"}, + ) + + logger.info("phone_book_address_render", count=len(clients)) + return templates.TemplateResponse( + "report_phone_book_address.html", + { + "request": request, + "user": user, + "clients": clients, + "q": q, + "phone": phone, + "client_ids": client_ids or [], + }, + ) + + +# ------------------------------ +# Reports: Envelope (PDF) +# ------------------------------ + +@app.post("/reports/envelope") +async def envelope_report_post(request: Request): + """Accept selected client IDs and redirect to GET for PDF download.""" + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + form = await request.form() + client_ids = form.getlist("client_ids") + if not client_ids: + return RedirectResponse(url="/rolodex", status_code=302) + + ids_param = "&".join([f"client_ids={cid}" for cid in client_ids]) + return RedirectResponse(url=f"/reports/envelope?{ids_param}&format=pdf", status_code=302) + + +@app.get("/reports/envelope") +async def envelope_report( + request: Request, + client_ids: List[int] | None = Query(None), + q: str | None = Query(None, description="Filter by name/company"), + phone: str | None = Query(None, description="Phone contains (optional)"), + format: str | None = Query("pdf", description="pdf output only"), + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + query = db.query(Client) + if client_ids: + query = query.filter(Client.id.in_(client_ids)) + else: + if q: + like = f"%{q}%" + query = query.filter( + or_(Client.first_name.ilike(like), Client.last_name.ilike(like), Client.company.ilike(like)) + ) + if phone: + # include clients that have a matching phone + query = query.join(Phone, isouter=True).filter(or_(Phone.phone_number.ilike(f"%{phone}%"), Phone.id == None)).distinct() # noqa: E711 + + clients = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()).all() + + # Always produce PDF + pdf_bytes = build_envelope_pdf(clients) + logger.info("envelope_pdf", count=len(clients)) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=envelopes.pdf"}, + ) + + +# ------------------------------ +# Reports: Rolodex Info (PDF) +# ------------------------------ + +@app.post("/reports/rolodex-info") +async def rolodex_info_post(request: Request): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + form = await request.form() + client_ids = form.getlist("client_ids") + if not client_ids: + return RedirectResponse(url="/rolodex", status_code=302) + + ids_param = "&".join([f"client_ids={cid}" for cid in client_ids]) + return RedirectResponse(url=f"/reports/rolodex-info?{ids_param}&format=pdf", status_code=302) + + +@app.get("/reports/rolodex-info") +async def rolodex_info_report( + request: Request, + client_ids: List[int] | None = Query(None), + q: str | None = Query(None, description="Filter by name/company"), + format: str | None = Query("pdf", description="pdf output only"), + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + query = db.query(Client).options(joinedload(Client.phones)) + if client_ids: + query = query.filter(Client.id.in_(client_ids)) + elif q: + like = f"%{q}%" + query = query.filter( + or_(Client.first_name.ilike(like), Client.last_name.ilike(like), Client.company.ilike(like)) + ) + + clients = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()).all() + + pdf_bytes = build_rolodex_info_pdf(clients) + logger.info("rolodex_info_pdf", count=len(clients)) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=rolodex_info.pdf"}, + ) + # ------------------------------ # JSON API: list/filter endpoints # ------------------------------ diff --git a/app/reporting.py b/app/reporting.py index 9945473..d27b305 100644 --- a/app/reporting.py +++ b/app/reporting.py @@ -164,3 +164,186 @@ def build_payments_detailed_pdf(payments: List[Payment]) -> bytes: 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) + diff --git a/app/templates/report_phone_book_address.html b/app/templates/report_phone_book_address.html new file mode 100644 index 0000000..aed151d --- /dev/null +++ b/app/templates/report_phone_book_address.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}Phone Book (Address + Phone) · Delphi Database{% endblock %} + +{% block content %} +
+
+ + Back + +

Phone Book (Address + Phone)

+ +
+ +
+
+ + + + + + + + + + + + + + {% if clients and clients|length > 0 %} + {% for c in clients %} + {% if c.phones and c.phones|length > 0 %} + {% for p in c.phones %} + + + + + + + + + + {% endfor %} + {% else %} + + + + + + + + + + {% endif %} + {% endfor %} + {% else %} + + {% endif %} + +
NameCompanyAddressCityStateZIPPhone
{{ c.last_name or '' }}, {{ c.first_name or '' }}{{ c.company or '' }}{{ c.address or '' }}{{ c.city or '' }}{{ c.state or '' }}{{ c.zip_code or '' }}{{ (p.phone_type ~ ': ' if p.phone_type) ~ (p.phone_number or '') }}
{{ c.last_name or '' }}, {{ c.first_name or '' }}{{ c.company or '' }}{{ c.address or '' }}{{ c.city or '' }}{{ c.state or '' }}{{ c.zip_code or '' }}
No data.
+
+
+
+{% endblock %} + + + diff --git a/app/templates/rolodex.html b/app/templates/rolodex.html index 66dd3f6..d0188f4 100644 --- a/app/templates/rolodex.html +++ b/app/templates/rolodex.html @@ -92,6 +92,15 @@ Phone Book CSV (Current Filter) + + Phone+Address (Selected) + + + Envelope (Selected) + + + Rolodex Info (Selected) + {% endcall %} {% endif %} diff --git a/static/js/custom.js b/static/js/custom.js index 6708056..117dfcc 100644 --- a/static/js/custom.js +++ b/static/js/custom.js @@ -81,6 +81,25 @@ document.addEventListener('DOMContentLoaded', function() { }); }); + // Submit selection to alternate endpoints using data-action + document.querySelectorAll('.js-submit-to').forEach(function(link) { + link.addEventListener('click', function(e) { + e.preventDefault(); + var container = link.closest('.table-responsive') || document; + var form = container.querySelector('form.js-answer-table'); + if (!form) form = document.querySelector('form.js-answer-table'); + if (!form) return; + var original = form.getAttribute('action'); + var action = link.getAttribute('data-action'); + if (action) form.setAttribute('action', action); + try { + form.submit(); + } finally { + if (original) form.setAttribute('action', original); + } + }); + }); + // Field help: show contextual help from data-help on focus function attachFieldHelp(container) { if (!container) return;