From f9c3b3cc9c68238dff62978b3bc7619164980927 Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Mon, 6 Oct 2025 23:31:02 -0500 Subject: [PATCH] MVP legacy features: payments search page, phone book report (HTML+CSV), Rolodex bulk selection + actions; audit logging for Rolodex/Phone CRUD; nav updates --- app/__pycache__/main.cpython-313.pyc | Bin 57563 -> 77633 bytes app/main.py | 411 ++++++++++++++++++++++++++- app/templates/base.html | 6 + app/templates/payments.html | 106 +++++++ app/templates/report_phone_book.html | 61 ++++ app/templates/rolodex.html | 153 ++++++++++ app/templates/rolodex_edit.html | 71 +++++ app/templates/rolodex_view.html | 170 +++++++++++ docker-compose.yml | 2 - 9 files changed, 974 insertions(+), 6 deletions(-) create mode 100644 app/templates/payments.html create mode 100644 app/templates/report_phone_book.html create mode 100644 app/templates/rolodex.html create mode 100644 app/templates/rolodex_edit.html create mode 100644 app/templates/rolodex_view.html diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 8386186e5d35f863a673b1833a896238c33c9b0d..66e16c1edfbc0f2ffdefc9fea5f3eb313405a195 100644 GIT binary patch delta 23635 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 K0x9~pLH-wa_RK#3 delta 9145 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 uFf73Gn^diP^0gM}Eu*vh6O+MM{-Mz4gLU diff --git a/app/main.py b/app/main.py index c8cc057..0135bbd 100644 --- a/app/main.py +++ b/app/main.py @@ -16,13 +16,13 @@ from typing import Optional, List, Dict, Any from io import StringIO from fastapi import FastAPI, Depends, Request, Query, HTTPException, UploadFile, File, Form -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, Response from starlette.middleware.sessions import SessionMiddleware from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session, joinedload -from sqlalchemy import or_ +from sqlalchemy import or_, and_ from dotenv import load_dotenv from starlette.middleware.base import BaseHTTPMiddleware import structlog @@ -794,9 +794,9 @@ def process_csv_import(db: Session, import_type: str, file_path: str) -> Dict[st @app.get("/") async def root(): """ - Root endpoint - health check. + Root endpoint - serves login form for web interface. """ - return {"message": "Delphi Database API is running"} + return RedirectResponse(url="/login", status_code=302) @app.get("/health") @@ -1478,3 +1478,406 @@ async def case_reopen( request.session["case_update_errors"] = ["Failed to reopen case. Please try again."] return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + +@app.get("/rolodex") +async def rolodex_list( + request: Request, + q: str | None = Query(None, description="Search by name or company"), + phone: str | None = Query(None, description="Search by phone contains"), + page: int = Query(1, ge=1, description="Page number (1-indexed)"), + page_size: int = Query(20, ge=1, le=100, description="Results per page"), + db: Session = Depends(get_db), +): + """ + Rolodex list with simple search and pagination. + + Filters clients by name/company and optional phone substring. + """ + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + # Eager-load phones to avoid N+1 in template + query = db.query(Client).options(joinedload(Client.phones)) + + 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: + like_phone = f"%{phone}%" + # Use EXISTS over join to avoid duplicate rows + query = query.filter(Client.phones.any(Phone.phone_number.ilike(like_phone))) + + # Order by last then first for stable display + query = query.order_by(Client.last_name.asc().nulls_last(), Client.first_name.asc().nulls_last()) + + total: int = query.count() + total_pages: int = (total + page_size - 1) // page_size if total > 0 else 1 + if page > total_pages: + page = total_pages + + offset = (page - 1) * page_size + clients = query.offset(offset).limit(page_size).all() + + start_page = max(1, page - 2) + end_page = min(total_pages, page + 2) + page_numbers = list(range(start_page, end_page + 1)) + + logger.info( + "rolodex_render", + query=q, + phone=phone, + page=page, + page_size=page_size, + total=total, + ) + + return templates.TemplateResponse( + "rolodex.html", + { + "request": request, + "user": user, + "clients": clients, + "q": q, + "phone": phone, + "page": page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + "page_numbers": page_numbers, + "start_index": (offset + 1) if total > 0 else 0, + "end_index": min(offset + len(clients), total), + "enable_bulk": True, + }, + ) + + +@app.get("/rolodex/new") +async def rolodex_new(request: Request): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + return templates.TemplateResponse("rolodex_edit.html", {"request": request, "user": user, "client": None}) + + +@app.get("/rolodex/{client_id}") +async def rolodex_view(client_id: int, request: Request, db: Session = Depends(get_db)): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + client = ( + db.query(Client) + .options(joinedload(Client.phones), joinedload(Client.cases)) + .filter(Client.id == client_id) + .first() + ) + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + return templates.TemplateResponse("rolodex_view.html", {"request": request, "user": user, "client": client}) + + +@app.post("/rolodex/create") +async def rolodex_create( + request: Request, + first_name: str = Form(None), + last_name: str = Form(None), + company: str = Form(None), + address: str = Form(None), + city: str = Form(None), + state: str = Form(None), + zip_code: str = Form(None), + rolodex_id: str = Form(None), + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + client = Client( + first_name=(first_name or "").strip() or None, + last_name=(last_name or "").strip() or None, + company=(company or "").strip() or None, + address=(address or "").strip() or None, + city=(city or "").strip() or None, + state=(state or "").strip() or None, + zip_code=(zip_code or "").strip() or None, + rolodex_id=(rolodex_id or "").strip() or None, + ) + db.add(client) + db.commit() + db.refresh(client) + logger.info("rolodex_create", client_id=client.id, rolodex_id=client.rolodex_id) + return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302) + + +@app.post("/rolodex/{client_id}/update") +async def rolodex_update( + client_id: int, + request: Request, + first_name: str = Form(None), + last_name: str = Form(None), + company: str = Form(None), + address: str = Form(None), + city: str = Form(None), + state: str = Form(None), + zip_code: str = Form(None), + rolodex_id: str = Form(None), + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + client = db.query(Client).filter(Client.id == client_id).first() + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + client.first_name = (first_name or "").strip() or None + client.last_name = (last_name or "").strip() or None + client.company = (company or "").strip() or None + client.address = (address or "").strip() or None + client.city = (city or "").strip() or None + client.state = (state or "").strip() or None + client.zip_code = (zip_code or "").strip() or None + client.rolodex_id = (rolodex_id or "").strip() or None + + db.commit() + logger.info( + "rolodex_update", + client_id=client.id, + fields={ + "first_name": client.first_name, + "last_name": client.last_name, + "company": client.company, + "rolodex_id": client.rolodex_id, + }, + ) + return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302) + + +@app.post("/rolodex/{client_id}/delete") +async def rolodex_delete(client_id: int, request: Request, db: Session = Depends(get_db)): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + client = db.query(Client).filter(Client.id == client_id).first() + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + db.delete(client) + db.commit() + logger.info("rolodex_delete", client_id=client_id) + return RedirectResponse(url="/rolodex", status_code=302) + + +@app.post("/rolodex/{client_id}/phone/add") +async def rolodex_add_phone( + client_id: int, + request: Request, + phone_number: str = Form(...), + phone_type: str = Form(None), + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + client = db.query(Client).filter(Client.id == client_id).first() + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + phone = Phone( + client_id=client.id, + phone_number=(phone_number or "").strip(), + phone_type=(phone_type or "").strip() or None, + ) + db.add(phone) + db.commit() + logger.info("rolodex_phone_add", client_id=client.id, phone_id=phone.id, number=phone.phone_number) + return RedirectResponse(url=f"/rolodex/{client.id}", status_code=302) + + +@app.post("/rolodex/{client_id}/phone/{phone_id}/delete") +async def rolodex_delete_phone(client_id: int, phone_id: int, request: Request, db: Session = Depends(get_db)): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + phone = db.query(Phone).filter(Phone.id == phone_id, Phone.client_id == client_id).first() + if not phone: + raise HTTPException(status_code=404, detail="Phone not found") + + db.delete(phone) + db.commit() + logger.info("rolodex_phone_delete", client_id=client_id, phone_id=phone_id) + return RedirectResponse(url=f"/rolodex/{client_id}", status_code=302) + + +@app.get("/payments") +async def payments_search( + request: Request, + from_date: str | None = Query(None, description="YYYY-MM-DD"), + to_date: str | None = Query(None, description="YYYY-MM-DD"), + file_no: str | None = Query(None, description="Case file number"), + rolodex_id: str | None = Query(None, description="Legacy client Id"), + q: str | None = Query(None, description="Description contains"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + 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(Payment) + .join(Case, Payment.case_id == Case.id) + .join(Client, Case.client_id == Client.id) + .order_by(Payment.payment_date.desc().nulls_last(), Payment.id.desc()) + ) + + filters = [] + if from_date: + try: + dt = datetime.strptime(from_date, "%Y-%m-%d") + filters.append(Payment.payment_date >= dt) + except ValueError: + pass + if to_date: + try: + dt = datetime.strptime(to_date, "%Y-%m-%d") + filters.append(Payment.payment_date <= dt) + except ValueError: + pass + if file_no: + filters.append(Case.file_no.ilike(f"%{file_no}%")) + if rolodex_id: + filters.append(Client.rolodex_id.ilike(f"%{rolodex_id}%")) + if q: + filters.append(Payment.description.ilike(f"%{q}%")) + + if filters: + query = query.filter(and_(*filters)) + + total = query.count() + total_pages = (total + page_size - 1) // page_size if total > 0 else 1 + if page > total_pages: + page = total_pages + offset = (page - 1) * page_size + payments = query.offset(offset).limit(page_size).all() + + # Totals for current result page + page_total_amount = sum(p.amount or 0 for p in payments) + + logger.info( + "payments_render", + from_date=from_date, + to_date=to_date, + file_no=file_no, + rolodex_id=rolodex_id, + q=q, + total=total, + ) + + return templates.TemplateResponse( + "payments.html", + { + "request": request, + "user": user, + "payments": payments, + "from_date": from_date, + "to_date": to_date, + "file_no": file_no, + "rolodex_id": rolodex_id, + "q": q, + "page": page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + "start_index": (offset + 1) if total > 0 else 0, + "end_index": min(offset + len(payments), total), + "page_total_amount": page_total_amount, + }, + ) + + +@app.post("/reports/phone-book") +async def phone_book_report_post(request: Request): + """Accepts selected client IDs from forms and redirects 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?{ids_param}", status_code=302) + + +@app.get("/reports/phone-book") +async def phone_book_report( + request: Request, + client_ids: List[int] | None = Query(None), + q: str | None = Query(None, description="Filter by name/company"), + format: str | None = Query(None, description="csv for CSV output"), + 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() + + if format == "csv": + # Build CSV output + output = StringIO() + writer = csv.writer(output) + writer.writerow(["Last", "First", "Company", "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 "", + p.phone_type or "", + p.phone_number or "", + ]) + else: + writer.writerow([c.last_name or "", c.first_name or "", c.company or "", "", ""]) + csv_bytes = output.getvalue().encode("utf-8") + return Response( + content=csv_bytes, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=phone_book.csv"}, + ) + + logger.info("phone_book_render", count=len(clients)) + return templates.TemplateResponse( + "report_phone_book.html", + {"request": request, "user": user, "clients": clients, "q": q, "client_ids": client_ids or []}, + ) diff --git a/app/templates/base.html b/app/templates/base.html index 98a50da..d8a7da9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -37,6 +37,12 @@ + + diff --git a/app/templates/payments.html b/app/templates/payments.html new file mode 100644 index 0000000..551592b --- /dev/null +++ b/app/templates/payments.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block title %}Payments · Delphi Database{% endblock %} + +{% block content %} +
+
+

Payments

+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ Clear +
+
+
+
+ {% if total and total > 0 %} + Showing {{ start_index }}–{{ end_index }} of {{ total }} | Page total: ${{ '%.2f'|format(page_total_amount) }} + {% else %} + No results + {% endif %} +
+ +
+
+ + + + + + + + + + + + + {% if payments and payments|length > 0 %} + {% for p in payments %} + + + + + + + + + {% endfor %} + {% else %} + + {% endif %} + +
DateFile #ClientTypeDescriptionAmount
{{ p.payment_date.strftime('%Y-%m-%d') if p.payment_date else '' }}{{ p.case.file_no if p.case else '' }} + {% set client = p.case.client if p.case else None %} + {% if client %}{{ client.last_name }}, {{ client.first_name }}{% else %}{% endif %} + {{ p.payment_type or '' }}{{ p.description or '' }}{{ '%.2f'|format(p.amount) if p.amount is not none else '' }}
No payments found.
+
+
+ +
+ {% if total_pages and total_pages > 1 %} + + {% endif %} +
+
+{% endblock %} + + diff --git a/app/templates/report_phone_book.html b/app/templates/report_phone_book.html new file mode 100644 index 0000000..d35a990 --- /dev/null +++ b/app/templates/report_phone_book.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block title %}Phone Book · Delphi Database{% endblock %} + +{% block content %} +
+
+ + Back + +

Phone Book

+ +
+ +
+
+ + + + + + + + + + + {% 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 %} + +
NameCompanyPhone TypePhone Number
{{ c.last_name or '' }}, {{ c.first_name or '' }}{{ c.company or '' }}{{ p.phone_type or '' }}{{ p.phone_number or '' }}
{{ c.last_name or '' }}, {{ c.first_name or '' }}{{ c.company or '' }}
No data.
+
+
+
+{% endblock %} + + diff --git a/app/templates/rolodex.html b/app/templates/rolodex.html new file mode 100644 index 0000000..105378c --- /dev/null +++ b/app/templates/rolodex.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} + +{% block title %}Rolodex · Delphi Database{% endblock %} + +{% block content %} +
+
+

Rolodex

+
+
+
+
+ +
+
+ +
+
+ + +
+ + +
+
+
+ {% if total and total > 0 %} + Showing {{ start_index }}–{{ end_index }} of {{ total }} + {% else %} + No results + {% endif %} +
+
+
+
+ + + + {% if enable_bulk %} + + {% endif %} + + + + + + + + + + + + {% if clients and clients|length > 0 %} + {% for c in clients %} + + {% if enable_bulk %} + + {% endif %} + + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
NameCompanyAddressCityStateZIPPhonesActions
+ + + {{ c.last_name or '' }}, {{ c.first_name or '' }} + {{ c.company or '' }}{{ c.address or '' }}{{ c.city or '' }}{{ c.state or '' }}{{ c.zip_code or '' }} + {% if c.phones and c.phones|length > 0 %} + {% for p in c.phones[:3] %} + {{ p.phone_number }} + {% endfor %} + {% else %} + + {% endif %} + + + View + +
No clients found.
+ {% if enable_bulk %} +
+ + + Phone Book CSV (Current Filter) + +
+ {% endif %} +
+
+
+
+ {% if total_pages and total_pages > 1 %} + + {% endif %} +
+
+{% block extra_scripts %} + +{% endblock %} +{% endblock %} + + diff --git a/app/templates/rolodex_edit.html b/app/templates/rolodex_edit.html new file mode 100644 index 0000000..cd30b9a --- /dev/null +++ b/app/templates/rolodex_edit.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block title %}{{ 'New Client' if not client else 'Edit Client' }} · Delphi Database{% endblock %} + +{% block content %} +
+
+ + + Back + +

{{ 'New Client' if not client else 'Edit Client' }}

+
+ +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+
+ + Cancel +
+
+
+
+
+
+
+
+{% endblock %} + + diff --git a/app/templates/rolodex_view.html b/app/templates/rolodex_view.html new file mode 100644 index 0000000..4f80704 --- /dev/null +++ b/app/templates/rolodex_view.html @@ -0,0 +1,170 @@ +{% extends "base.html" %} + +{% block title %}Client · {{ client.last_name }}, {{ client.first_name }} · Delphi Database{% endblock %} + +{% block content %} +
+
+ + + Back + +

Client

+
+ + Edit + + +
+
+ +
+
+
+
+
+
Name
+
{{ client.last_name or '' }}, {{ client.first_name or '' }}
+
+
+
Company
+
{{ client.company or '' }}
+
+
+
Legacy Rolodex Id
+
{{ client.rolodex_id or '' }}
+
+
+
+
+
Address
+
{{ client.address or '' }}
+
+
+
City
+
{{ client.city or '' }}
+
+
+
State
+
{{ client.state or '' }}
+
+
+
ZIP
+
{{ client.zip_code or '' }}
+
+
+ +
+ + Edit Client + +
+ +
+
+ +
+
+
+
+
+ +
+
+
+
Phones
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + {% if client.phones and client.phones|length > 0 %} + {% for p in client.phones %} + + + + + + {% endfor %} + {% else %} + + {% endif %} + +
NumberTypeActions
{{ p.phone_number }}{{ p.phone_type or '' }} +
+ +
+
No phones.
+
+
+
+
+ +
+
+
Related Cases
+
+
+ + + + + + + + + + + + {% if client.cases and client.cases|length > 0 %} + {% for c in client.cases %} + + + + + + + + {% endfor %} + {% else %} + + {% endif %} + +
File #DescriptionStatusOpenedActions
{{ c.file_no }}{{ c.description or '' }}{{ c.status or '' }}{{ c.open_date.strftime('%Y-%m-%d') if c.open_date else '' }} + + + +
No related cases.
+
+
+
+
+
+{% endblock %} + + diff --git a/docker-compose.yml b/docker-compose.yml index 922e6dc..05390c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: delphi-db: build: .