Files
claudetools/api/routers/billable_time.py
Mike Swanson 390b10b32c Complete Phase 6: MSP Work Tracking with Context Recall System
Implements production-ready MSP platform with cross-machine persistent memory for Claude.

API Implementation:
- 130 REST API endpoints across 21 entities
- JWT authentication on all endpoints
- AES-256-GCM encryption for credentials
- Automatic audit logging
- Complete OpenAPI documentation

Database:
- 43 tables in MariaDB (172.16.3.20:3306)
- 42 SQLAlchemy models with modern 2.0 syntax
- Full Alembic migration system
- 99.1% CRUD test pass rate

Context Recall System (Phase 6):
- Cross-machine persistent memory via database
- Automatic context injection via Claude Code hooks
- Automatic context saving after task completion
- 90-95% token reduction with compression utilities
- Relevance scoring with time decay
- Tag-based semantic search
- One-command setup script

Security Features:
- JWT tokens with Argon2 password hashing
- AES-256-GCM encryption for all sensitive data
- Comprehensive audit trail for credentials
- HMAC tamper detection
- Secure configuration management

Test Results:
- Phase 3: 38/38 CRUD tests passing (100%)
- Phase 4: 34/35 core API tests passing (97.1%)
- Phase 5: 62/62 extended API tests passing (100%)
- Phase 6: 10/10 compression tests passing (100%)
- Overall: 144/145 tests passing (99.3%)

Documentation:
- Comprehensive architecture guides
- Setup automation scripts
- API documentation at /api/docs
- Complete test reports
- Troubleshooting guides

Project Status: 95% Complete (Production-Ready)
Phase 7 (optional work context APIs) remains for future enhancement.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 06:00:26 -07:00

566 lines
17 KiB
Python

"""
Billable Time API router for ClaudeTools.
This module defines all REST API endpoints for managing billable time entries, including
CRUD operations with proper authentication, validation, and error handling.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from api.database import get_db
from api.middleware.auth import get_current_user
from api.schemas.billable_time import (
BillableTimeCreate,
BillableTimeResponse,
BillableTimeUpdate,
)
from api.services import billable_time_service
# Create router with prefix and tags
router = APIRouter()
@router.get(
"",
response_model=dict,
summary="List all billable time entries",
description="Retrieve a paginated list of all billable time entries",
status_code=status.HTTP_200_OK,
)
def list_billable_time_entries(
skip: int = Query(
default=0,
ge=0,
description="Number of records to skip for pagination"
),
limit: int = Query(
default=100,
ge=1,
le=1000,
description="Maximum number of records to return (max 1000)"
),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
List all billable time entries with pagination.
- **skip**: Number of entries to skip (default: 0)
- **limit**: Maximum number of entries to return (default: 100, max: 1000)
Returns a list of billable time entries with pagination metadata.
**Example Request:**
```
GET /api/billable-time?skip=0&limit=50
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"total": 25,
"skip": 0,
"limit": 50,
"billable_time": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"client_id": "456e7890-e89b-12d3-a456-426614174001",
"session_id": "789e0123-e89b-12d3-a456-426614174002",
"start_time": "2024-01-15T09:00:00Z",
"duration_minutes": 120,
"hourly_rate": 150.00,
"total_amount": 300.00,
"is_billable": true,
"description": "Database optimization work",
"category": "development",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
```
"""
try:
entries, total = billable_time_service.get_billable_time_entries(db, skip, limit)
return {
"total": total,
"skip": skip,
"limit": limit,
"billable_time": [BillableTimeResponse.model_validate(entry) for entry in entries]
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve billable time entries: {str(e)}"
)
@router.get(
"/{billable_time_id}",
response_model=BillableTimeResponse,
summary="Get billable time entry by ID",
description="Retrieve a single billable time entry by its unique identifier",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Billable time entry found and returned",
"model": BillableTimeResponse,
},
404: {
"description": "Billable time entry not found",
"content": {
"application/json": {
"example": {"detail": "Billable time entry with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
}
},
},
},
)
def get_billable_time_entry(
billable_time_id: UUID,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Get a specific billable time entry by ID.
- **billable_time_id**: UUID of the billable time entry to retrieve
Returns the complete billable time entry details.
**Example Request:**
```
GET /api/billable-time/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"work_item_id": "012e3456-e89b-12d3-a456-426614174003",
"session_id": "789e0123-e89b-12d3-a456-426614174002",
"client_id": "456e7890-e89b-12d3-a456-426614174001",
"start_time": "2024-01-15T09:00:00Z",
"end_time": "2024-01-15T11:00:00Z",
"duration_minutes": 120,
"hourly_rate": 150.00,
"total_amount": 300.00,
"is_billable": true,
"description": "Database optimization and performance tuning",
"category": "development",
"notes": "Optimized queries and added indexes",
"invoiced_at": null,
"invoice_id": null,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
"""
entry = billable_time_service.get_billable_time_by_id(db, billable_time_id)
return BillableTimeResponse.model_validate(entry)
@router.post(
"",
response_model=BillableTimeResponse,
summary="Create new billable time entry",
description="Create a new billable time entry with the provided details",
status_code=status.HTTP_201_CREATED,
responses={
201: {
"description": "Billable time entry created successfully",
"model": BillableTimeResponse,
},
404: {
"description": "Referenced client, session, or work item not found",
"content": {
"application/json": {
"example": {"detail": "Client with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
}
},
},
422: {
"description": "Validation error",
"content": {
"application/json": {
"example": {
"detail": [
{
"loc": ["body", "client_id"],
"msg": "field required",
"type": "value_error.missing"
}
]
}
}
},
},
},
)
def create_billable_time_entry(
billable_time_data: BillableTimeCreate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Create a new billable time entry.
Requires a valid JWT token with appropriate permissions.
**Example Request:**
```json
POST /api/billable-time
Authorization: Bearer <token>
Content-Type: application/json
{
"client_id": "456e7890-e89b-12d3-a456-426614174001",
"session_id": "789e0123-e89b-12d3-a456-426614174002",
"work_item_id": "012e3456-e89b-12d3-a456-426614174003",
"start_time": "2024-01-15T09:00:00Z",
"end_time": "2024-01-15T11:00:00Z",
"duration_minutes": 120,
"hourly_rate": 150.00,
"total_amount": 300.00,
"is_billable": true,
"description": "Database optimization and performance tuning",
"category": "development",
"notes": "Optimized queries and added indexes"
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"client_id": "456e7890-e89b-12d3-a456-426614174001",
"start_time": "2024-01-15T09:00:00Z",
"duration_minutes": 120,
"hourly_rate": 150.00,
"total_amount": 300.00,
"is_billable": true,
"category": "development",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
"""
entry = billable_time_service.create_billable_time(db, billable_time_data)
return BillableTimeResponse.model_validate(entry)
@router.put(
"/{billable_time_id}",
response_model=BillableTimeResponse,
summary="Update billable time entry",
description="Update an existing billable time entry's details",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Billable time entry updated successfully",
"model": BillableTimeResponse,
},
404: {
"description": "Billable time entry, client, session, or work item not found",
"content": {
"application/json": {
"example": {"detail": "Billable time entry with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
}
},
},
422: {
"description": "Validation error",
"content": {
"application/json": {
"example": {"detail": "Invalid client_id"}
}
},
},
},
)
def update_billable_time_entry(
billable_time_id: UUID,
billable_time_data: BillableTimeUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Update an existing billable time entry.
- **billable_time_id**: UUID of the billable time entry to update
Only provided fields will be updated. All fields are optional.
**Example Request:**
```json
PUT /api/billable-time/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>
Content-Type: application/json
{
"duration_minutes": 150,
"total_amount": 375.00,
"notes": "Additional optimization work performed"
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"client_id": "456e7890-e89b-12d3-a456-426614174001",
"start_time": "2024-01-15T09:00:00Z",
"duration_minutes": 150,
"hourly_rate": 150.00,
"total_amount": 375.00,
"notes": "Additional optimization work performed",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T14:20:00Z"
}
```
"""
entry = billable_time_service.update_billable_time(db, billable_time_id, billable_time_data)
return BillableTimeResponse.model_validate(entry)
@router.delete(
"/{billable_time_id}",
response_model=dict,
summary="Delete billable time entry",
description="Delete a billable time entry by its ID",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Billable time entry deleted successfully",
"content": {
"application/json": {
"example": {
"message": "Billable time entry deleted successfully",
"billable_time_id": "123e4567-e89b-12d3-a456-426614174000"
}
}
},
},
404: {
"description": "Billable time entry not found",
"content": {
"application/json": {
"example": {"detail": "Billable time entry with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
}
},
},
},
)
def delete_billable_time_entry(
billable_time_id: UUID,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Delete a billable time entry.
- **billable_time_id**: UUID of the billable time entry to delete
This is a permanent operation and cannot be undone.
**Example Request:**
```
DELETE /api/billable-time/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"message": "Billable time entry deleted successfully",
"billable_time_id": "123e4567-e89b-12d3-a456-426614174000"
}
```
"""
return billable_time_service.delete_billable_time(db, billable_time_id)
@router.get(
"/by-session/{session_id}",
response_model=dict,
summary="Get billable time by session",
description="Retrieve billable time entries for a specific session",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Billable time entries retrieved successfully",
"content": {
"application/json": {
"example": {
"total": 3,
"skip": 0,
"limit": 100,
"billable_time": []
}
}
},
},
},
)
def get_billable_time_by_session(
session_id: UUID,
skip: int = Query(
default=0,
ge=0,
description="Number of records to skip for pagination"
),
limit: int = Query(
default=100,
ge=1,
le=1000,
description="Maximum number of records to return (max 1000)"
),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Get billable time entries for a specific session.
- **session_id**: UUID of the session
- **skip**: Number of entries to skip (default: 0)
- **limit**: Maximum number of entries to return (default: 100, max: 1000)
Returns a paginated list of billable time entries for the session.
**Example Request:**
```
GET /api/billable-time/by-session/789e0123-e89b-12d3-a456-426614174002?skip=0&limit=50
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"total": 3,
"skip": 0,
"limit": 50,
"billable_time": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"session_id": "789e0123-e89b-12d3-a456-426614174002",
"duration_minutes": 120,
"total_amount": 300.00,
"description": "Database optimization",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
```
"""
try:
entries, total = billable_time_service.get_billable_time_by_session(db, session_id, skip, limit)
return {
"total": total,
"skip": skip,
"limit": limit,
"billable_time": [BillableTimeResponse.model_validate(entry) for entry in entries]
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve billable time entries: {str(e)}"
)
@router.get(
"/by-work-item/{work_item_id}",
response_model=dict,
summary="Get billable time by work item",
description="Retrieve billable time entries for a specific work item",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Billable time entries retrieved successfully",
"content": {
"application/json": {
"example": {
"total": 5,
"skip": 0,
"limit": 100,
"billable_time": []
}
}
},
},
},
)
def get_billable_time_by_work_item(
work_item_id: UUID,
skip: int = Query(
default=0,
ge=0,
description="Number of records to skip for pagination"
),
limit: int = Query(
default=100,
ge=1,
le=1000,
description="Maximum number of records to return (max 1000)"
),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Get billable time entries for a specific work item.
- **work_item_id**: UUID of the work item
- **skip**: Number of entries to skip (default: 0)
- **limit**: Maximum number of entries to return (default: 100, max: 1000)
Returns a paginated list of billable time entries for the work item.
**Example Request:**
```
GET /api/billable-time/by-work-item/012e3456-e89b-12d3-a456-426614174003?skip=0&limit=50
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"total": 5,
"skip": 0,
"limit": 50,
"billable_time": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"work_item_id": "012e3456-e89b-12d3-a456-426614174003",
"duration_minutes": 120,
"total_amount": 300.00,
"description": "Bug fix and testing",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
```
"""
try:
entries, total = billable_time_service.get_billable_time_by_work_item(db, work_item_id, skip, limit)
return {
"total": total,
"skip": skip,
"limit": limit,
"billable_time": [BillableTimeResponse.model_validate(entry) for entry in entries]
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve billable time entries: {str(e)}"
)