diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 8386186..66e16c1 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ 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: .