progress
This commit is contained in:
@@ -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
515
app/api/file_management.py
Normal 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
577
app/api/timers.py
Normal 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]
|
||||
Reference in New Issue
Block a user