diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index e89d1de..3874ad4 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/reporting.cpython-313.pyc b/app/__pycache__/reporting.cpython-313.pyc index f12146a..f818510 100644 Binary files a/app/__pycache__/reporting.cpython-313.pyc and b/app/__pycache__/reporting.cpython-313.pyc differ 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 %} +
| Name | +Company | +Address | +City | +State | +ZIP | +Phone | +
|---|---|---|---|---|---|---|
| {{ 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. | ||||||