537 lines
16 KiB
Python
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
|
|
} |