Files
delphi-database/app/api/timers.py
HotSwapp ae4484381f progress
2025-08-16 10:05:42 -05:00

577 lines
18 KiB
Python

"""
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]