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