progress
This commit is contained in:
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