This commit is contained in:
HotSwapp
2025-08-16 10:05:42 -05:00
parent 0347284556
commit ae4484381f
15 changed files with 3966 additions and 77 deletions

View File

@@ -14,7 +14,7 @@ from enum import Enum
from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
from fastapi import Path as PathParam
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, HTMLResponse
from fastapi import WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session, joinedload
@@ -29,7 +29,11 @@ from app.auth.security import get_current_user, verify_token
from app.utils.responses import BulkOperationResponse, ErrorDetail
from app.utils.logging import StructuredLogger
from app.services.cache import cache_get_json, cache_set_json
from app.models.billing import BillingBatch, BillingBatchFile
from app.models.billing import (
BillingBatch, BillingBatchFile, BillingStatement, StatementTemplate,
BillingStatementItem, StatementStatus
)
from app.services.billing import BillingStatementService, StatementGenerationError
router = APIRouter()
@@ -1605,3 +1609,417 @@ async def download_latest_statement(
media_type="text/html",
filename=latest_path.name,
)
# =====================================================================
# NEW BILLING STATEMENT MANAGEMENT ENDPOINTS
# =====================================================================
from pydantic import BaseModel as PydanticBaseModel, Field as PydanticField
from typing import Union
class StatementTemplateResponse(PydanticBaseModel):
"""Response model for statement templates"""
id: int
name: str
description: Optional[str] = None
is_default: bool
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_by: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class StatementTemplateCreate(PydanticBaseModel):
"""Create statement template request"""
name: str = PydanticField(..., min_length=1, max_length=100)
description: Optional[str] = None
header_template: Optional[str] = None
footer_template: Optional[str] = None
css_styles: Optional[str] = None
is_default: bool = False
class StatementTemplateUpdate(PydanticBaseModel):
"""Update statement template request"""
name: Optional[str] = PydanticField(None, min_length=1, max_length=100)
description: Optional[str] = None
header_template: Optional[str] = None
footer_template: Optional[str] = None
css_styles: Optional[str] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
class BillingStatementResponse(PydanticBaseModel):
"""Response model for billing statements"""
id: int
statement_number: str
file_no: str
customer_id: Optional[str] = None
period_start: date
period_end: date
statement_date: date
due_date: Optional[date] = None
previous_balance: float
current_charges: float
payments_credits: float
total_due: float
trust_balance: float
trust_applied: float
status: StatementStatus
billed_transaction_count: int
approved_by: Optional[str] = None
approved_at: Optional[datetime] = None
sent_by: Optional[str] = None
sent_at: Optional[datetime] = None
created_at: Optional[datetime] = None
created_by: Optional[str] = None
custom_footer: Optional[str] = None
internal_notes: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class BillingStatementCreate(PydanticBaseModel):
"""Create billing statement request"""
file_no: str
period_start: date
period_end: date
template_id: Optional[int] = None
custom_footer: Optional[str] = None
class PaginatedStatementsResponse(PydanticBaseModel):
"""Paginated statements response"""
items: List[BillingStatementResponse]
total: int
class PaginatedTemplatesResponse(PydanticBaseModel):
"""Paginated templates response"""
items: List[StatementTemplateResponse]
total: int
# Statement Templates endpoints
@router.get("/statement-templates", response_model=Union[List[StatementTemplateResponse], PaginatedTemplatesResponse])
async def list_statement_templates(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
active_only: bool = Query(False, description="Filter to active templates only"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List statement templates"""
query = db.query(StatementTemplate)
if active_only:
query = query.filter(StatementTemplate.is_active == True)
query = query.order_by(StatementTemplate.is_default.desc(), StatementTemplate.name)
if include_total:
total = query.count()
templates = query.offset(skip).limit(limit).all()
return {"items": templates, "total": total}
templates = query.offset(skip).limit(limit).all()
return templates
@router.post("/statement-templates", response_model=StatementTemplateResponse)
async def create_statement_template(
template_data: StatementTemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new statement template"""
# Check if template name already exists
existing = db.query(StatementTemplate).filter(StatementTemplate.name == template_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template name already exists"
)
# If this is set as default, unset other defaults
if template_data.is_default:
db.query(StatementTemplate).filter(StatementTemplate.is_default == True).update({
StatementTemplate.is_default: False
})
template = StatementTemplate(
**template_data.model_dump(),
created_by=current_user.username,
is_active=True
)
db.add(template)
db.commit()
db.refresh(template)
return template
@router.get("/statement-templates/{template_id}", response_model=StatementTemplateResponse)
async def get_statement_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific statement template"""
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
return template
@router.put("/statement-templates/{template_id}", response_model=StatementTemplateResponse)
async def update_statement_template(
template_id: int,
template_data: StatementTemplateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a statement template"""
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Check if new name conflicts with existing template
if template_data.name and template_data.name != template.name:
existing = db.query(StatementTemplate).filter(
StatementTemplate.name == template_data.name,
StatementTemplate.id != template_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template name already exists"
)
# If setting as default, unset other defaults
if template_data.is_default:
db.query(StatementTemplate).filter(
StatementTemplate.is_default == True,
StatementTemplate.id != template_id
).update({StatementTemplate.is_default: False})
# Update fields
for field, value in template_data.model_dump(exclude_unset=True).items():
setattr(template, field, value)
db.commit()
db.refresh(template)
return template
@router.delete("/statement-templates/{template_id}")
async def delete_statement_template(
template_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a statement template"""
template = db.query(StatementTemplate).filter(StatementTemplate.id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Check if template is being used by statements
statement_count = db.query(BillingStatement).filter(BillingStatement.template_id == template_id).count()
if statement_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete template: {statement_count} statements are using this template"
)
db.delete(template)
db.commit()
return {"message": "Template deleted successfully"}
# Billing Statements endpoints
@router.get("/billing-statements", response_model=Union[List[BillingStatementResponse], PaginatedStatementsResponse])
async def list_billing_statements(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
file_no: Optional[str] = Query(None, description="Filter by file number"),
status: Optional[StatementStatus] = Query(None, description="Filter by statement status"),
start_date: Optional[date] = Query(None, description="Filter statements from this date"),
end_date: Optional[date] = Query(None, description="Filter statements to this date"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List billing statements with filtering"""
query = db.query(BillingStatement).options(
joinedload(BillingStatement.file),
joinedload(BillingStatement.customer),
joinedload(BillingStatement.template)
)
if file_no:
query = query.filter(BillingStatement.file_no == file_no)
if status:
query = query.filter(BillingStatement.status == status)
if start_date:
query = query.filter(BillingStatement.statement_date >= start_date)
if end_date:
query = query.filter(BillingStatement.statement_date <= end_date)
query = query.order_by(BillingStatement.statement_date.desc())
if include_total:
total = query.count()
statements = query.offset(skip).limit(limit).all()
return {"items": statements, "total": total}
statements = query.offset(skip).limit(limit).all()
return statements
@router.post("/billing-statements", response_model=BillingStatementResponse)
async def create_billing_statement(
statement_data: BillingStatementCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new billing statement"""
try:
service = BillingStatementService(db)
statement = service.create_statement(
file_no=statement_data.file_no,
period_start=statement_data.period_start,
period_end=statement_data.period_end,
template_id=statement_data.template_id,
custom_footer=statement_data.custom_footer,
created_by=current_user.username
)
return statement
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("/billing-statements/{statement_id}", response_model=BillingStatementResponse)
async def get_billing_statement(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific billing statement"""
statement = db.query(BillingStatement).options(
joinedload(BillingStatement.file),
joinedload(BillingStatement.customer),
joinedload(BillingStatement.template),
joinedload(BillingStatement.statement_items)
).filter(BillingStatement.id == statement_id).first()
if not statement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Statement not found"
)
return statement
@router.post("/billing-statements/{statement_id}/generate-html")
async def generate_statement_html(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Generate HTML content for a statement"""
try:
service = BillingStatementService(db)
html_content = service.generate_statement_html(statement_id)
return {"html_content": html_content}
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/billing-statements/{statement_id}/approve", response_model=BillingStatementResponse)
async def approve_billing_statement(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Approve a statement and mark transactions as billed"""
try:
service = BillingStatementService(db)
statement = service.approve_statement(statement_id, current_user.username)
return statement
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/billing-statements/{statement_id}/send", response_model=BillingStatementResponse)
async def mark_statement_sent(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Mark statement as sent to client"""
try:
service = BillingStatementService(db)
statement = service.mark_statement_sent(statement_id, current_user.username)
return statement
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("/billing-statements/{statement_id}/preview")
async def preview_billing_statement(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get HTML preview of billing statement"""
try:
service = BillingStatementService(db)
html_content = service.generate_statement_html(statement_id)
return HTMLResponse(content=html_content)
except StatementGenerationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/billing-statements/{statement_id}")
async def delete_billing_statement(
statement_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a billing statement (only if in draft status)"""
statement = db.query(BillingStatement).filter(BillingStatement.id == statement_id).first()
if not statement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Statement not found"
)
if statement.status != StatementStatus.DRAFT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only draft statements can be deleted"
)
db.delete(statement)
db.commit()
return {"message": "Statement deleted successfully"}

515
app/api/file_management.py Normal file
View File

@@ -0,0 +1,515 @@
"""
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
]

577
app/api/timers.py Normal file
View File

@@ -0,0 +1,577 @@
"""
Timer and time tracking API endpoints
"""
from typing import List, Optional, Union
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field, ConfigDict
from app.database.base import get_db
from app.models import Timer, TimeEntry, TimerTemplate, TimerStatus, TimerType, User
from app.services.timers import TimerService, TimerServiceError
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 TimerResponse(BaseModel):
"""Response model for timers"""
id: int
user_id: int
file_no: Optional[str] = None
customer_id: Optional[str] = None
title: str
description: Optional[str] = None
timer_type: TimerType
status: TimerStatus
total_seconds: int
hourly_rate: Optional[float] = None
is_billable: bool
task_category: Optional[str] = None
started_at: Optional[datetime] = None
last_started_at: Optional[datetime] = None
last_paused_at: Optional[datetime] = None
stopped_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
notes: Optional[str] = None
# Computed properties
total_hours: Optional[float] = None
is_active: Optional[bool] = None
current_session_seconds: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_timer(cls, timer: Timer) -> "TimerResponse":
"""Create response from Timer model with computed properties"""
return cls(
**timer.__dict__,
total_hours=timer.total_hours,
is_active=timer.is_active,
current_session_seconds=timer.get_current_session_seconds()
)
class TimerCreate(BaseModel):
"""Create timer request"""
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
file_no: Optional[str] = None
customer_id: Optional[str] = None
timer_type: TimerType = TimerType.BILLABLE
hourly_rate: Optional[float] = Field(None, gt=0)
task_category: Optional[str] = None
template_id: Optional[int] = None
class TimerUpdate(BaseModel):
"""Update timer request"""
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
file_no: Optional[str] = None
customer_id: Optional[str] = None
timer_type: Optional[TimerType] = None
hourly_rate: Optional[float] = Field(None, gt=0)
task_category: Optional[str] = None
notes: Optional[str] = None
class TimeEntryResponse(BaseModel):
"""Response model for time entries"""
id: int
timer_id: Optional[int] = None
user_id: int
file_no: Optional[str] = None
customer_id: Optional[str] = None
title: str
description: Optional[str] = None
entry_type: TimerType
hours: float
entry_date: datetime
hourly_rate: Optional[float] = None
is_billable: bool
billed: bool
ledger_id: Optional[int] = None
task_category: Optional[str] = None
notes: Optional[str] = None
approved: bool
approved_by: Optional[str] = None
approved_at: Optional[datetime] = None
created_at: Optional[datetime] = None
created_by: Optional[str] = None
# Computed property
calculated_amount: Optional[float] = None
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_time_entry(cls, entry: TimeEntry) -> "TimeEntryResponse":
"""Create response from TimeEntry model with computed properties"""
return cls(
**entry.__dict__,
calculated_amount=entry.calculated_amount
)
class TimeEntryCreate(BaseModel):
"""Create time entry request"""
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
file_no: Optional[str] = None
customer_id: Optional[str] = None
hours: float = Field(..., gt=0, le=24)
entry_date: datetime
hourly_rate: Optional[float] = Field(None, gt=0)
entry_type: TimerType = TimerType.BILLABLE
task_category: Optional[str] = None
class TimeEntryFromTimerCreate(BaseModel):
"""Create time entry from timer request"""
title: Optional[str] = None
description: Optional[str] = None
hours_override: Optional[float] = Field(None, gt=0, le=24)
entry_date: Optional[datetime] = None
class TimerTemplateResponse(BaseModel):
"""Response model for timer templates"""
id: int
name: str
title_template: str
description_template: Optional[str] = None
timer_type: TimerType
task_category: Optional[str] = None
default_rate: Optional[float] = None
is_billable: bool
is_active: bool
usage_count: int
created_by: Optional[str] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class TimerTemplateCreate(BaseModel):
"""Create timer template request"""
name: str = Field(..., min_length=1, max_length=100)
title_template: str = Field(..., min_length=1, max_length=200)
description_template: Optional[str] = None
timer_type: TimerType = TimerType.BILLABLE
task_category: Optional[str] = None
default_rate: Optional[float] = Field(None, gt=0)
is_billable: bool = True
class TimerStatistics(BaseModel):
"""Timer statistics response"""
period_days: int
total_hours: float
billable_hours: float
non_billable_hours: float
active_timers: int
time_entries_created: int
time_entries_billed: int
billable_percentage: float
class PaginatedTimersResponse(BaseModel):
"""Paginated timers response"""
items: List[TimerResponse]
total: int
class PaginatedTimeEntriesResponse(BaseModel):
"""Paginated time entries response"""
items: List[TimeEntryResponse]
total: int
# Timer endpoints
@router.get("/", response_model=Union[List[TimerResponse], PaginatedTimersResponse])
async def list_timers(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
status: Optional[TimerStatus] = Query(None, description="Filter by timer status"),
file_no: Optional[str] = Query(None, description="Filter by file number"),
active_only: bool = Query(False, description="Show only active (running/paused) timers"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List timers for the current user"""
service = TimerService(db)
if active_only:
timers = service.get_active_timers(current_user.id)
timer_responses = [TimerResponse.from_timer(timer) for timer in timers]
if include_total:
return {"items": timer_responses, "total": len(timer_responses)}
return timer_responses
# Get all timers with filtering
timers = service.get_user_timers(
user_id=current_user.id,
status_filter=status,
file_no=file_no,
limit=limit + skip if not include_total else 1000 # Get more for pagination
)
# Apply pagination manually for now
if include_total:
total = len(timers)
paginated_timers = timers[skip:skip + limit]
timer_responses = [TimerResponse.from_timer(timer) for timer in paginated_timers]
return {"items": timer_responses, "total": total}
paginated_timers = timers[skip:skip + limit]
timer_responses = [TimerResponse.from_timer(timer) for timer in paginated_timers]
return timer_responses
@router.post("/", response_model=TimerResponse)
async def create_timer(
timer_data: TimerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new timer"""
try:
service = TimerService(db)
timer = service.create_timer(
user_id=current_user.id,
**timer_data.model_dump()
)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("/{timer_id}", response_model=TimerResponse)
async def get_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific timer"""
try:
service = TimerService(db)
timer = service._get_user_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
@router.put("/{timer_id}", response_model=TimerResponse)
async def update_timer(
timer_id: int,
timer_data: TimerUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a timer"""
try:
service = TimerService(db)
timer = service._get_user_timer(timer_id, current_user.id)
# Update fields
for field, value in timer_data.model_dump(exclude_unset=True).items():
setattr(timer, field, value)
db.commit()
db.refresh(timer)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/{timer_id}")
async def delete_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a timer"""
try:
service = TimerService(db)
service.delete_timer(timer_id, current_user.id)
return {"message": "Timer deleted successfully"}
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Timer control endpoints
@router.post("/{timer_id}/start", response_model=TimerResponse)
async def start_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Start a timer"""
try:
service = TimerService(db)
timer = service.start_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{timer_id}/pause", response_model=TimerResponse)
async def pause_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Pause a timer"""
try:
service = TimerService(db)
timer = service.pause_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{timer_id}/resume", response_model=TimerResponse)
async def resume_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Resume a paused timer"""
try:
service = TimerService(db)
timer = service.resume_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{timer_id}/stop", response_model=TimerResponse)
async def stop_timer(
timer_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Stop a timer"""
try:
service = TimerService(db)
timer = service.stop_timer(timer_id, current_user.id)
return TimerResponse.from_timer(timer)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Time entry endpoints
@router.get("/time-entries/", response_model=Union[List[TimeEntryResponse], PaginatedTimeEntriesResponse])
async def list_time_entries(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
include_total: bool = Query(False, description="When true, returns {items, total} instead of a plain list"),
file_no: Optional[str] = Query(None, description="Filter by file number"),
billed: Optional[bool] = Query(None, description="Filter by billing status"),
start_date: Optional[date] = Query(None, description="Filter entries from this date"),
end_date: Optional[date] = Query(None, description="Filter entries to this date"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List time entries for the current user"""
query = db.query(TimeEntry).filter(TimeEntry.user_id == current_user.id)
if file_no:
query = query.filter(TimeEntry.file_no == file_no)
if billed is not None:
query = query.filter(TimeEntry.billed == billed)
if start_date:
query = query.filter(TimeEntry.entry_date >= start_date)
if end_date:
query = query.filter(TimeEntry.entry_date <= end_date)
query = query.order_by(TimeEntry.entry_date.desc())
if include_total:
total = query.count()
entries = query.offset(skip).limit(limit).all()
entry_responses = [TimeEntryResponse.from_time_entry(entry) for entry in entries]
return {"items": entry_responses, "total": total}
entries = query.offset(skip).limit(limit).all()
entry_responses = [TimeEntryResponse.from_time_entry(entry) for entry in entries]
return entry_responses
@router.post("/time-entries/", response_model=TimeEntryResponse)
async def create_manual_time_entry(
entry_data: TimeEntryCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a manual time entry"""
try:
service = TimerService(db)
entry = service.create_manual_time_entry(
user_id=current_user.id,
**entry_data.model_dump()
)
return TimeEntryResponse.from_time_entry(entry)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{timer_id}/create-entry", response_model=TimeEntryResponse)
async def create_time_entry_from_timer(
timer_id: int,
entry_data: TimeEntryFromTimerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a time entry from a completed timer"""
try:
service = TimerService(db)
entry = service.create_time_entry_from_timer(
timer_id=timer_id,
user_id=current_user.id,
**entry_data.model_dump()
)
return TimeEntryResponse.from_time_entry(entry)
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/time-entries/{entry_id}/convert-to-billing")
async def convert_time_entry_to_billing(
entry_id: int,
transaction_code: str = Query("TIME", description="Transaction code for billing entry"),
notes: Optional[str] = Query(None, description="Additional notes for billing entry"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Convert a time entry to a billable ledger transaction"""
try:
service = TimerService(db)
ledger_entry = service.convert_time_entry_to_ledger(
time_entry_id=entry_id,
user_id=current_user.id,
transaction_code=transaction_code,
notes=notes
)
return {
"message": "Time entry converted to billing successfully",
"ledger_id": ledger_entry.id,
"amount": ledger_entry.amount
}
except TimerServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Timer templates endpoints
@router.get("/templates/", response_model=List[TimerTemplateResponse])
async def list_timer_templates(
active_only: bool = Query(True, description="Show only active templates"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List timer templates"""
query = db.query(TimerTemplate)
if active_only:
query = query.filter(TimerTemplate.is_active == True)
templates = query.order_by(TimerTemplate.usage_count.desc(), TimerTemplate.name).all()
return templates
@router.post("/templates/", response_model=TimerTemplateResponse)
async def create_timer_template(
template_data: TimerTemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new timer template"""
# Check if template name already exists
existing = db.query(TimerTemplate).filter(TimerTemplate.name == template_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template name already exists"
)
template = TimerTemplate(
**template_data.model_dump(),
created_by=current_user.username
)
db.add(template)
db.commit()
db.refresh(template)
return template
# Statistics endpoint
@router.get("/statistics/", response_model=TimerStatistics)
async def get_timer_statistics(
days: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get timer statistics for the current user"""
service = TimerService(db)
stats = service.get_timer_statistics(current_user.id, days)
return TimerStatistics(**stats)
# Active timers quick access
@router.get("/active/", response_model=List[TimerResponse])
async def get_active_timers(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all active timers for the current user"""
service = TimerService(db)
timers = service.get_active_timers(current_user.id)
return [TimerResponse.from_timer(timer) for timer in timers]