MVP legacy features: payments search page, phone book report (HTML+CSV), Rolodex bulk selection + actions; audit logging for Rolodex/Phone CRUD; nav updates

This commit is contained in:
HotSwapp
2025-10-06 23:31:02 -05:00
parent d456ae4f39
commit f9c3b3cc9c
9 changed files with 974 additions and 6 deletions

View File

@@ -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 []},
)