515 lines
16 KiB
Python
515 lines
16 KiB
Python
"""
|
|
Enhanced file management API endpoints
|
|
"""
|
|
from typing import List, Optional, Union, Dict, Any
|
|
from datetime import date, datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel, Field, ConfigDict
|
|
|
|
from app.database.base import get_db
|
|
from app.models import (
|
|
File, FileStatus, FileType, Employee, User, FileStatusHistory,
|
|
FileTransferHistory, FileArchiveInfo
|
|
)
|
|
from app.services.file_management import FileManagementService, FileManagementError, FileStatusWorkflow
|
|
from app.auth.security import get_current_user
|
|
from app.utils.logging import app_logger
|
|
|
|
router = APIRouter()
|
|
logger = app_logger
|
|
|
|
|
|
# Pydantic schemas for requests/responses
|
|
class FileStatusChangeRequest(BaseModel):
|
|
"""Request to change file status"""
|
|
new_status: str = Field(..., description="New status code")
|
|
notes: Optional[str] = Field(None, description="Notes about the status change")
|
|
validate_transition: bool = Field(True, description="Whether to validate the status transition")
|
|
|
|
|
|
class FileClosureRequest(BaseModel):
|
|
"""Request to close a file"""
|
|
force_close: bool = Field(False, description="Force closure even if there are warnings")
|
|
final_payment_amount: Optional[float] = Field(None, gt=0, description="Final payment amount")
|
|
closing_notes: Optional[str] = Field(None, description="Notes about file closure")
|
|
|
|
|
|
class FileReopenRequest(BaseModel):
|
|
"""Request to reopen a closed file"""
|
|
new_status: str = Field("ACTIVE", description="Status to reopen file to")
|
|
notes: Optional[str] = Field(None, description="Notes about reopening")
|
|
|
|
|
|
class FileTransferRequest(BaseModel):
|
|
"""Request to transfer file to different attorney"""
|
|
new_attorney_id: str = Field(..., description="Employee ID of new attorney")
|
|
transfer_reason: Optional[str] = Field(None, description="Reason for transfer")
|
|
|
|
|
|
class FileArchiveRequest(BaseModel):
|
|
"""Request to archive a file"""
|
|
archive_location: Optional[str] = Field(None, description="Physical or digital archive location")
|
|
notes: Optional[str] = Field(None, description="Archive notes")
|
|
|
|
|
|
class BulkStatusUpdateRequest(BaseModel):
|
|
"""Request to update status for multiple files"""
|
|
file_numbers: List[str] = Field(..., max_length=100, description="List of file numbers")
|
|
new_status: str = Field(..., description="New status for all files")
|
|
notes: Optional[str] = Field(None, description="Notes for all status changes")
|
|
|
|
|
|
class FileStatusHistoryResponse(BaseModel):
|
|
"""Response for file status history"""
|
|
id: int
|
|
old_status: str
|
|
new_status: str
|
|
change_date: datetime
|
|
changed_by: str
|
|
notes: Optional[str] = None
|
|
system_generated: bool
|
|
|
|
|
|
class FileTransferHistoryResponse(BaseModel):
|
|
"""Response for file transfer history"""
|
|
id: int
|
|
old_attorney_id: str
|
|
new_attorney_id: str
|
|
transfer_date: datetime
|
|
authorized_by_name: str
|
|
reason: Optional[str] = None
|
|
old_hourly_rate: Optional[float] = None
|
|
new_hourly_rate: Optional[float] = None
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class FileClosureSummaryResponse(BaseModel):
|
|
"""Response for file closure summary"""
|
|
file_no: str
|
|
closure_date: date
|
|
actions_taken: List[str]
|
|
warnings: List[str]
|
|
final_balance: float
|
|
trust_balance: float
|
|
|
|
|
|
class FileValidationResponse(BaseModel):
|
|
"""Response for file validation checks"""
|
|
file_no: str
|
|
current_status: str
|
|
valid_transitions: List[str]
|
|
can_close: bool
|
|
blocking_issues: List[str]
|
|
warnings: List[str]
|
|
|
|
|
|
class ClosureCandidateResponse(BaseModel):
|
|
"""Response for file closure candidates"""
|
|
file_no: str
|
|
client_name: str
|
|
attorney: str
|
|
opened_date: date
|
|
last_activity: Optional[date] = None
|
|
outstanding_balance: float
|
|
status: str
|
|
|
|
|
|
class BulkOperationResult(BaseModel):
|
|
"""Result of bulk operation"""
|
|
successful: List[str]
|
|
failed: List[Dict[str, str]]
|
|
total: int
|
|
|
|
|
|
# File status management endpoints
|
|
@router.post("/{file_no}/change-status")
|
|
async def change_file_status(
|
|
file_no: str,
|
|
request: FileStatusChangeRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Change file status with workflow validation"""
|
|
try:
|
|
service = FileManagementService(db)
|
|
file_obj = service.change_file_status(
|
|
file_no=file_no,
|
|
new_status=request.new_status,
|
|
user_id=current_user.id,
|
|
notes=request.notes,
|
|
validate_transition=request.validate_transition
|
|
)
|
|
|
|
return {
|
|
"message": f"File {file_no} status changed to {request.new_status}",
|
|
"file_no": file_obj.file_no,
|
|
"old_status": file_obj.status,
|
|
"new_status": request.new_status
|
|
}
|
|
except FileManagementError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
@router.get("/{file_no}/valid-transitions")
|
|
async def get_valid_status_transitions(
|
|
file_no: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get valid status transitions 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"
|
|
)
|
|
|
|
workflow = FileStatusWorkflow()
|
|
valid_transitions = workflow.get_valid_transitions(file_obj.status)
|
|
|
|
return {
|
|
"file_no": file_no,
|
|
"current_status": file_obj.status,
|
|
"valid_transitions": valid_transitions
|
|
}
|
|
|
|
|
|
@router.get("/{file_no}/closure-validation", response_model=FileValidationResponse)
|
|
async def validate_file_closure(
|
|
file_no: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Validate if file is ready for closure"""
|
|
try:
|
|
service = FileManagementService(db)
|
|
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"
|
|
)
|
|
|
|
validation_result = service._validate_file_closure(file_obj)
|
|
workflow = FileStatusWorkflow()
|
|
|
|
return FileValidationResponse(
|
|
file_no=file_no,
|
|
current_status=file_obj.status,
|
|
valid_transitions=workflow.get_valid_transitions(file_obj.status),
|
|
can_close=validation_result["can_close"],
|
|
blocking_issues=validation_result.get("blocking_issues", []),
|
|
warnings=validation_result.get("warnings", [])
|
|
)
|
|
except FileManagementError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
@router.post("/{file_no}/close", response_model=FileClosureSummaryResponse)
|
|
async def close_file(
|
|
file_no: str,
|
|
request: FileClosureRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Close a file with automated closure process"""
|
|
try:
|
|
service = FileManagementService(db)
|
|
closure_summary = service.close_file(
|
|
file_no=file_no,
|
|
user_id=current_user.id,
|
|
force_close=request.force_close,
|
|
final_payment_amount=request.final_payment_amount,
|
|
closing_notes=request.closing_notes
|
|
)
|
|
|
|
return FileClosureSummaryResponse(**closure_summary)
|
|
except FileManagementError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
@router.post("/{file_no}/reopen")
|
|
async def reopen_file(
|
|
file_no: str,
|
|
request: FileReopenRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Reopen a closed file"""
|
|
try:
|
|
service = FileManagementService(db)
|
|
file_obj = service.reopen_file(
|
|
file_no=file_no,
|
|
user_id=current_user.id,
|
|
new_status=request.new_status,
|
|
notes=request.notes
|
|
)
|
|
|
|
return {
|
|
"message": f"File {file_no} reopened with status {request.new_status}",
|
|
"file_no": file_obj.file_no,
|
|
"new_status": file_obj.status
|
|
}
|
|
except FileManagementError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
@router.post("/{file_no}/transfer")
|
|
async def transfer_file(
|
|
file_no: str,
|
|
request: FileTransferRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Transfer file to a different attorney"""
|
|
try:
|
|
service = FileManagementService(db)
|
|
file_obj = service.transfer_file(
|
|
file_no=file_no,
|
|
new_attorney_id=request.new_attorney_id,
|
|
user_id=current_user.id,
|
|
transfer_reason=request.transfer_reason
|
|
)
|
|
|
|
return {
|
|
"message": f"File {file_no} transferred to attorney {request.new_attorney_id}",
|
|
"file_no": file_obj.file_no,
|
|
"new_attorney": file_obj.empl_num,
|
|
"new_rate": file_obj.rate_per_hour
|
|
}
|
|
except FileManagementError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
@router.post("/{file_no}/archive")
|
|
async def archive_file(
|
|
file_no: str,
|
|
request: FileArchiveRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Archive a closed file"""
|
|
try:
|
|
service = FileManagementService(db)
|
|
file_obj = service.archive_file(
|
|
file_no=file_no,
|
|
user_id=current_user.id,
|
|
archive_location=request.archive_location,
|
|
notes=request.notes
|
|
)
|
|
|
|
return {
|
|
"message": f"File {file_no} has been archived",
|
|
"file_no": file_obj.file_no,
|
|
"status": file_obj.status,
|
|
"archive_location": request.archive_location
|
|
}
|
|
except FileManagementError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
# File history endpoints
|
|
@router.get("/{file_no}/status-history", response_model=List[FileStatusHistoryResponse])
|
|
async def get_file_status_history(
|
|
file_no: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get status change history for a file"""
|
|
# Verify file exists
|
|
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"
|
|
)
|
|
|
|
service = FileManagementService(db)
|
|
history = service.get_file_status_history(file_no)
|
|
|
|
return [FileStatusHistoryResponse(**item) for item in history]
|
|
|
|
|
|
@router.get("/{file_no}/transfer-history", response_model=List[FileTransferHistoryResponse])
|
|
async def get_file_transfer_history(
|
|
file_no: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get transfer history for a file"""
|
|
# Verify file exists
|
|
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"
|
|
)
|
|
|
|
transfers = db.query(FileTransferHistory).filter(
|
|
FileTransferHistory.file_no == file_no
|
|
).order_by(FileTransferHistory.transfer_date.desc()).all()
|
|
|
|
return transfers
|
|
|
|
|
|
# Bulk operations
|
|
@router.post("/bulk-status-update", response_model=BulkOperationResult)
|
|
async def bulk_status_update(
|
|
request: BulkStatusUpdateRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Update status for multiple files"""
|
|
try:
|
|
service = FileManagementService(db)
|
|
results = service.bulk_status_update(
|
|
file_numbers=request.file_numbers,
|
|
new_status=request.new_status,
|
|
user_id=current_user.id,
|
|
notes=request.notes
|
|
)
|
|
|
|
return BulkOperationResult(**results)
|
|
except FileManagementError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
# File queries and reports
|
|
@router.get("/by-status/{status}")
|
|
async def get_files_by_status(
|
|
status: str,
|
|
attorney_id: Optional[str] = Query(None, description="Filter by attorney ID"),
|
|
limit: int = Query(100, ge=1, le=500, description="Maximum number of files to return"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get files by status with optional attorney filter"""
|
|
service = FileManagementService(db)
|
|
files = service.get_files_by_status(status, attorney_id, limit)
|
|
|
|
return [
|
|
{
|
|
"file_no": f.file_no,
|
|
"client_name": f"{f.owner.first or ''} {f.owner.last}".strip() if f.owner else "Unknown",
|
|
"regarding": f.regarding,
|
|
"attorney": f.empl_num,
|
|
"opened_date": f.opened,
|
|
"closed_date": f.closed,
|
|
"status": f.status
|
|
}
|
|
for f in files
|
|
]
|
|
|
|
|
|
@router.get("/closure-candidates", response_model=List[ClosureCandidateResponse])
|
|
async def get_closure_candidates(
|
|
days_inactive: int = Query(90, ge=30, le=365, description="Days since last activity"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get files that are candidates for closure"""
|
|
service = FileManagementService(db)
|
|
candidates = service.get_closure_candidates(days_inactive)
|
|
|
|
return [ClosureCandidateResponse(**candidate) for candidate in candidates]
|
|
|
|
|
|
# Lookup endpoints
|
|
@router.get("/statuses")
|
|
async def get_file_statuses(
|
|
active_only: bool = Query(True, description="Return only active statuses"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get available file statuses"""
|
|
query = db.query(FileStatus)
|
|
|
|
if active_only:
|
|
query = query.filter(FileStatus.active == True)
|
|
|
|
statuses = query.order_by(FileStatus.sort_order, FileStatus.status_code).all()
|
|
|
|
return [
|
|
{
|
|
"status_code": s.status_code,
|
|
"description": s.description,
|
|
"active": s.active,
|
|
"send": s.send,
|
|
"footer_code": s.footer_code
|
|
}
|
|
for s in statuses
|
|
]
|
|
|
|
|
|
@router.get("/types")
|
|
async def get_file_types(
|
|
active_only: bool = Query(True, description="Return only active file types"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get available file types"""
|
|
query = db.query(FileType)
|
|
|
|
if active_only:
|
|
query = query.filter(FileType.active == True)
|
|
|
|
types = query.order_by(FileType.type_code).all()
|
|
|
|
return [
|
|
{
|
|
"type_code": t.type_code,
|
|
"description": t.description,
|
|
"default_rate": t.default_rate,
|
|
"active": t.active
|
|
}
|
|
for t in types
|
|
]
|
|
|
|
|
|
@router.get("/attorneys")
|
|
async def get_attorneys(
|
|
active_only: bool = Query(True, description="Return only active attorneys"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""Get available attorneys for file assignment"""
|
|
query = db.query(Employee)
|
|
|
|
if active_only:
|
|
query = query.filter(Employee.active == True)
|
|
|
|
attorneys = query.order_by(Employee.last_name, Employee.first_name).all()
|
|
|
|
return [
|
|
{
|
|
"empl_num": a.empl_num,
|
|
"name": f"{a.first_name or ''} {a.last_name}".strip(),
|
|
"title": a.title,
|
|
"rate_per_hour": a.rate_per_hour,
|
|
"active": a.active
|
|
}
|
|
for a in attorneys
|
|
] |