577 lines
18 KiB
Python
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] |