Files
delphi-database/app/api/files.py
2025-08-14 19:16:28 -05:00

537 lines
16 KiB
Python

"""
File Management API endpoints
"""
from typing import List, Optional, Dict, Any, Union
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, func, and_, desc
from datetime import date, datetime
from app.database.base import get_db
from app.api.search_highlight import build_query_tokens
from app.services.query_utils import tokenized_ilike_filter, apply_pagination, apply_sorting, paginate_with_total
from app.models.files import File
from app.models.rolodex import Rolodex
from app.models.ledger import Ledger
from app.models.lookups import Employee, FileType, FileStatus
from app.models.user import User
from app.auth.security import get_current_user
from app.services.cache import invalidate_search_cache
router = APIRouter()
# Pydantic schemas
from pydantic import BaseModel
from pydantic.config import ConfigDict
class FileBase(BaseModel):
file_no: str
id: str # Rolodex ID (file owner)
regarding: Optional[str] = None
empl_num: str
file_type: str
opened: date
closed: Optional[date] = None
status: str
footer_code: Optional[str] = None
opposing: Optional[str] = None
rate_per_hour: float
memo: Optional[str] = None
class FileCreate(FileBase):
pass
class FileUpdate(BaseModel):
id: Optional[str] = None
regarding: Optional[str] = None
empl_num: Optional[str] = None
file_type: Optional[str] = None
opened: Optional[date] = None
closed: Optional[date] = None
status: Optional[str] = None
footer_code: Optional[str] = None
opposing: Optional[str] = None
rate_per_hour: Optional[float] = None
memo: Optional[str] = None
class FileResponse(FileBase):
# Financial balances
trust_bal: float = 0.0
hours: float = 0.0
hourly_fees: float = 0.0
flat_fees: float = 0.0
disbursements: float = 0.0
credit_bal: float = 0.0
total_charges: float = 0.0
amount_owing: float = 0.0
transferable: float = 0.0
model_config = ConfigDict(from_attributes=True)
class PaginatedFilesResponse(BaseModel):
items: List[FileResponse]
total: int
@router.get("/", response_model=Union[List[FileResponse], PaginatedFilesResponse])
async def list_files(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
search: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None),
employee_filter: Optional[str] = Query(None),
sort_by: Optional[str] = Query(None, description="Sort by: file_no, client, opened, closed, status, amount_owing, total_charges"),
sort_dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List files with pagination and filtering"""
query = db.query(File)
if search:
# DRY: tokenize and apply case-insensitive search consistently with search endpoints
tokens = build_query_tokens(search)
filter_expr = tokenized_ilike_filter(tokens, [
File.file_no,
File.id,
File.regarding,
File.file_type,
File.memo,
])
if filter_expr is not None:
query = query.filter(filter_expr)
if status_filter:
query = query.filter(File.status == status_filter)
if employee_filter:
query = query.filter(File.empl_num == employee_filter)
# Sorting (whitelisted)
query = apply_sorting(
query,
sort_by,
sort_dir,
allowed={
"file_no": [File.file_no],
"client": [File.id],
"opened": [File.opened],
"closed": [File.closed],
"status": [File.status],
"amount_owing": [File.amount_owing],
"total_charges": [File.total_charges],
},
)
files, total = paginate_with_total(query, skip, limit, include_total)
if include_total:
return {"items": files, "total": total or 0}
return files
@router.get("/{file_no}", response_model=FileResponse)
async def get_file(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get specific file by file number"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
return file_obj
@router.post("/", response_model=FileResponse)
async def create_file(
file_data: FileCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create new file"""
# Check if file number already exists
existing = db.query(File).filter(File.file_no == file_data.file_no).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File number already exists"
)
file_obj = File(**file_data.model_dump())
db.add(file_obj)
db.commit()
db.refresh(file_obj)
try:
await invalidate_search_cache()
except Exception:
pass
return file_obj
@router.put("/{file_no}", response_model=FileResponse)
async def update_file(
file_no: str,
file_data: FileUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Update fields
for field, value in file_data.model_dump(exclude_unset=True).items():
setattr(file_obj, field, value)
db.commit()
db.refresh(file_obj)
try:
await invalidate_search_cache()
except Exception:
pass
return file_obj
@router.delete("/{file_no}")
async def delete_file(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
db.delete(file_obj)
db.commit()
try:
await invalidate_search_cache()
except Exception:
pass
return {"message": "File deleted successfully"}
@router.get("/stats/summary")
async def get_file_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get file statistics and summary"""
total_files = db.query(File).count()
active_files = db.query(File).filter(File.status == "ACTIVE").count()
# Get status breakdown
status_stats = db.query(
File.status,
func.count(File.file_no).label('count')
).group_by(File.status).all()
# Get file type breakdown
type_stats = db.query(
File.file_type,
func.count(File.file_no).label('count')
).group_by(File.file_type).all()
# Get employee breakdown
employee_stats = db.query(
File.empl_num,
func.count(File.file_no).label('count')
).group_by(File.empl_num).all()
# Financial summary
financial_summary = db.query(
func.sum(File.total_charges).label('total_charges'),
func.sum(File.amount_owing).label('total_owing'),
func.sum(File.trust_bal).label('total_trust'),
func.sum(File.hours).label('total_hours')
).first()
return {
"total_files": total_files,
"active_files": active_files,
"status_breakdown": [{"status": s[0], "count": s[1]} for s in status_stats],
"type_breakdown": [{"type": t[0], "count": t[1]} for t in type_stats],
"employee_breakdown": [{"employee": e[0], "count": e[1]} for e in employee_stats],
"financial_summary": {
"total_charges": float(financial_summary.total_charges or 0),
"total_owing": float(financial_summary.total_owing or 0),
"total_trust": float(financial_summary.total_trust or 0),
"total_hours": float(financial_summary.total_hours or 0)
}
}
@router.get("/lookups/file-types")
async def get_file_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available file types"""
file_types = db.query(FileType).filter(FileType.active == True).all()
return [{"code": ft.type_code, "description": ft.description} for ft in file_types]
@router.get("/lookups/file-statuses")
async def get_file_statuses(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available file statuses"""
statuses = db.query(FileStatus).filter(FileStatus.active == True).order_by(FileStatus.sort_order).all()
return [{"code": s.status_code, "description": s.description} for s in statuses]
@router.get("/lookups/employees")
async def get_employees(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get active employees"""
employees = db.query(Employee).filter(Employee.active == True).all()
return [{"code": e.empl_num, "name": f"{e.first_name} {e.last_name}"} for e in employees]
@router.get("/{file_no}/financial-summary")
async def get_file_financial_summary(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get detailed financial summary for a file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Get recent ledger entries
recent_entries = db.query(Ledger)\
.filter(Ledger.file_no == file_no)\
.order_by(desc(Ledger.date))\
.limit(10)\
.all()
# Get unbilled entries
unbilled_entries = db.query(Ledger)\
.filter(and_(Ledger.file_no == file_no, Ledger.billed == "N"))\
.all()
unbilled_total = sum(entry.amount for entry in unbilled_entries)
return {
"file_no": file_no,
"financial_data": {
"trust_balance": file_obj.trust_bal,
"hours_total": file_obj.hours,
"hourly_fees": file_obj.hourly_fees,
"flat_fees": file_obj.flat_fees,
"disbursements": file_obj.disbursements,
"total_charges": file_obj.total_charges,
"amount_owing": file_obj.amount_owing,
"credit_balance": file_obj.credit_bal,
"transferable": file_obj.transferable,
"unbilled_amount": unbilled_total
},
"recent_entries": [
{
"date": entry.date.isoformat() if entry.date else None,
"amount": entry.amount,
"description": entry.note,
"billed": entry.billed == "Y"
} for entry in recent_entries
],
"unbilled_count": len(unbilled_entries)
}
@router.get("/{file_no}/client-info")
async def get_file_client_info(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get client information for a file"""
file_obj = db.query(File)\
.options(joinedload(File.owner))\
.filter(File.file_no == file_no)\
.first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
client = file_obj.owner
if not client:
return {"file_no": file_no, "client": None}
return {
"file_no": file_no,
"client": {
"id": client.id,
"name": f"{client.first or ''} {client.last}".strip(),
"email": client.email,
"city": client.city,
"state": client.abrev,
"group": client.group
}
}
@router.post("/{file_no}/close")
async def close_file(
file_no: str,
close_date: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Close a file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
file_obj.closed = close_date or date.today()
file_obj.status = "CLOSED"
db.commit()
db.refresh(file_obj)
return {"message": f"File {file_no} closed successfully", "closed_date": file_obj.closed}
@router.post("/{file_no}/reopen")
async def reopen_file(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Reopen a closed file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
file_obj.closed = None
file_obj.status = "ACTIVE"
db.commit()
db.refresh(file_obj)
return {"message": f"File {file_no} reopened successfully"}
@router.get("/search/advanced")
async def advanced_file_search(
file_no: Optional[str] = Query(None),
client_name: Optional[str] = Query(None),
regarding: Optional[str] = Query(None),
file_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
employee: Optional[str] = Query(None),
opened_after: Optional[date] = Query(None),
opened_before: Optional[date] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Advanced file search with multiple criteria"""
query = db.query(File).options(joinedload(File.owner))
if file_no:
query = query.filter(File.file_no.contains(file_no))
if client_name:
# SQLite-safe concatenation for first + last name
full_name_expr = (func.coalesce(Rolodex.first, '') + ' ' + func.coalesce(Rolodex.last, ''))
query = query.join(Rolodex).filter(
or_(
Rolodex.first.contains(client_name),
Rolodex.last.contains(client_name),
full_name_expr.contains(client_name)
)
)
if regarding:
query = query.filter(File.regarding.contains(regarding))
if file_type:
query = query.filter(File.file_type == file_type)
if status:
query = query.filter(File.status == status)
if employee:
query = query.filter(File.empl_num == employee)
if opened_after:
query = query.filter(File.opened >= opened_after)
if opened_before:
query = query.filter(File.opened <= opened_before)
# Get total count for pagination
total = query.count()
# Apply pagination
files = query.offset(skip).limit(limit).all()
# Format results with client names
results = []
for file_obj in files:
client = file_obj.owner
client_name = f"{client.first or ''} {client.last}".strip() if client else "Unknown"
results.append({
"file_no": file_obj.file_no,
"client_id": file_obj.id,
"client_name": client_name,
"regarding": file_obj.regarding,
"file_type": file_obj.file_type,
"status": file_obj.status,
"employee": file_obj.empl_num,
"opened": file_obj.opened.isoformat() if file_obj.opened else None,
"closed": file_obj.closed.isoformat() if file_obj.closed else None,
"amount_owing": file_obj.amount_owing,
"total_charges": file_obj.total_charges
})
return {
"files": results,
"total": total,
"skip": skip,
"limit": limit
}