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:
411
app/main.py
411
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 []},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user