Files
claudetools/api/services/billable_time_service.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

408 lines
14 KiB
Python

"""
BillableTime service layer for business logic and database operations.
This module handles all database operations for billable time entries, providing a clean
separation between the API routes and data access layer.
"""
from typing import Optional
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from api.models.billable_time import BillableTime as BillableTimeModel
from api.models.client import Client
from api.models.session import Session as SessionModel
from api.models.work_item import WorkItem
from api.schemas.billable_time import BillableTimeCreate, BillableTimeUpdate
def get_billable_time_entries(db: Session, skip: int = 0, limit: int = 100) -> tuple[list[BillableTimeModel], int]:
"""
Retrieve a paginated list of billable time entries.
Args:
db: Database session
skip: Number of records to skip (for pagination)
limit: Maximum number of records to return
Returns:
tuple: (list of billable time entries, total count)
Example:
```python
entries, total = get_billable_time_entries(db, skip=0, limit=50)
print(f"Retrieved {len(entries)} of {total} billable time entries")
```
"""
# Get total count
total = db.query(BillableTimeModel).count()
# Get paginated results, ordered by start_time descending (newest first)
entries = (
db.query(BillableTimeModel)
.order_by(BillableTimeModel.start_time.desc())
.offset(skip)
.limit(limit)
.all()
)
return entries, total
def get_billable_time_by_id(db: Session, billable_time_id: UUID) -> BillableTimeModel:
"""
Retrieve a single billable time entry by its ID.
Args:
db: Database session
billable_time_id: UUID of the billable time entry to retrieve
Returns:
BillableTimeModel: The billable time entry object
Raises:
HTTPException: 404 if billable time entry not found
Example:
```python
entry = get_billable_time_by_id(db, billable_time_id)
print(f"Found entry: {entry.description}")
```
"""
entry = db.query(BillableTimeModel).filter(BillableTimeModel.id == str(billable_time_id)).first()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Billable time entry with ID {billable_time_id} not found"
)
return entry
def get_billable_time_by_session(db: Session, session_id: UUID, skip: int = 0, limit: int = 100) -> tuple[list[BillableTimeModel], int]:
"""
Retrieve billable time entries for a specific session.
Args:
db: Database session
session_id: UUID of the session
skip: Number of records to skip (for pagination)
limit: Maximum number of records to return
Returns:
tuple: (list of billable time entries, total count)
Example:
```python
entries, total = get_billable_time_by_session(db, session_id)
print(f"Found {total} billable time entries for session")
```
"""
# Get total count
total = db.query(BillableTimeModel).filter(BillableTimeModel.session_id == str(session_id)).count()
# Get paginated results
entries = (
db.query(BillableTimeModel)
.filter(BillableTimeModel.session_id == str(session_id))
.order_by(BillableTimeModel.start_time.desc())
.offset(skip)
.limit(limit)
.all()
)
return entries, total
def get_billable_time_by_work_item(db: Session, work_item_id: UUID, skip: int = 0, limit: int = 100) -> tuple[list[BillableTimeModel], int]:
"""
Retrieve billable time entries for a specific work item.
Args:
db: Database session
work_item_id: UUID of the work item
skip: Number of records to skip (for pagination)
limit: Maximum number of records to return
Returns:
tuple: (list of billable time entries, total count)
Example:
```python
entries, total = get_billable_time_by_work_item(db, work_item_id)
print(f"Found {total} billable time entries for work item")
```
"""
# Get total count
total = db.query(BillableTimeModel).filter(BillableTimeModel.work_item_id == str(work_item_id)).count()
# Get paginated results
entries = (
db.query(BillableTimeModel)
.filter(BillableTimeModel.work_item_id == str(work_item_id))
.order_by(BillableTimeModel.start_time.desc())
.offset(skip)
.limit(limit)
.all()
)
return entries, total
def create_billable_time(db: Session, billable_time_data: BillableTimeCreate) -> BillableTimeModel:
"""
Create a new billable time entry.
Args:
db: Database session
billable_time_data: Billable time creation data
Returns:
BillableTimeModel: The created billable time entry object
Raises:
HTTPException: 404 if referenced client, session, or work item not found
HTTPException: 422 if validation fails
HTTPException: 500 if database error occurs
Example:
```python
entry_data = BillableTimeCreate(
client_id="123e4567-e89b-12d3-a456-426614174000",
start_time=datetime.now(),
duration_minutes=60,
hourly_rate=150.00,
total_amount=150.00,
description="Database optimization",
category="development"
)
entry = create_billable_time(db, entry_data)
print(f"Created billable time entry: {entry.id}")
```
"""
try:
# Validate foreign keys
# Client is required
client = db.query(Client).filter(Client.id == str(billable_time_data.client_id)).first()
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Client with ID {billable_time_data.client_id} not found"
)
# Session is optional
if billable_time_data.session_id:
session = db.query(SessionModel).filter(SessionModel.id == str(billable_time_data.session_id)).first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session with ID {billable_time_data.session_id} not found"
)
# Work item is optional
if billable_time_data.work_item_id:
work_item = db.query(WorkItem).filter(WorkItem.id == str(billable_time_data.work_item_id)).first()
if not work_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Work item with ID {billable_time_data.work_item_id} not found"
)
# Create new billable time entry instance
db_billable_time = BillableTimeModel(**billable_time_data.model_dump())
# Add to database
db.add(db_billable_time)
db.commit()
db.refresh(db_billable_time)
return db_billable_time
except HTTPException:
db.rollback()
raise
except IntegrityError as e:
db.rollback()
# Handle foreign key constraint violations
if "client_id" in str(e.orig):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid client_id: {billable_time_data.client_id}"
)
elif "session_id" in str(e.orig):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid session_id: {billable_time_data.session_id}"
)
elif "work_item_id" in str(e.orig):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid work_item_id: {billable_time_data.work_item_id}"
)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Database error: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create billable time entry: {str(e)}"
)
def update_billable_time(db: Session, billable_time_id: UUID, billable_time_data: BillableTimeUpdate) -> BillableTimeModel:
"""
Update an existing billable time entry.
Args:
db: Database session
billable_time_id: UUID of the billable time entry to update
billable_time_data: Billable time update data (only provided fields will be updated)
Returns:
BillableTimeModel: The updated billable time entry object
Raises:
HTTPException: 404 if billable time entry, client, session, or work item not found
HTTPException: 422 if validation fails
HTTPException: 500 if database error occurs
Example:
```python
update_data = BillableTimeUpdate(
duration_minutes=90,
total_amount=225.00
)
entry = update_billable_time(db, billable_time_id, update_data)
print(f"Updated billable time entry: {entry.description}")
```
"""
# Get existing billable time entry
entry = get_billable_time_by_id(db, billable_time_id)
try:
# Update only provided fields
update_data = billable_time_data.model_dump(exclude_unset=True)
# Validate foreign keys if being updated
if "client_id" in update_data and update_data["client_id"]:
client = db.query(Client).filter(Client.id == str(update_data["client_id"])).first()
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Client with ID {update_data['client_id']} not found"
)
if "session_id" in update_data and update_data["session_id"]:
session = db.query(SessionModel).filter(SessionModel.id == str(update_data["session_id"])).first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session with ID {update_data['session_id']} not found"
)
if "work_item_id" in update_data and update_data["work_item_id"]:
work_item = db.query(WorkItem).filter(WorkItem.id == str(update_data["work_item_id"])).first()
if not work_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Work item with ID {update_data['work_item_id']} not found"
)
# Validate end_time if being updated along with start_time
if "end_time" in update_data and update_data["end_time"]:
start_time = update_data.get("start_time", entry.start_time)
if update_data["end_time"] < start_time:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="end_time must be after start_time"
)
# Apply updates
for field, value in update_data.items():
setattr(entry, field, value)
db.commit()
db.refresh(entry)
return entry
except HTTPException:
db.rollback()
raise
except IntegrityError as e:
db.rollback()
if "client_id" in str(e.orig):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid client_id"
)
elif "session_id" in str(e.orig):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid session_id"
)
elif "work_item_id" in str(e.orig):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid work_item_id"
)
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Database error: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update billable time entry: {str(e)}"
)
def delete_billable_time(db: Session, billable_time_id: UUID) -> dict:
"""
Delete a billable time entry by its ID.
Args:
db: Database session
billable_time_id: UUID of the billable time entry to delete
Returns:
dict: Success message
Raises:
HTTPException: 404 if billable time entry not found
HTTPException: 500 if database error occurs
Example:
```python
result = delete_billable_time(db, billable_time_id)
print(result["message"]) # "Billable time entry deleted successfully"
```
"""
# Get existing billable time entry (raises 404 if not found)
entry = get_billable_time_by_id(db, billable_time_id)
try:
db.delete(entry)
db.commit()
return {
"message": "Billable time entry deleted successfully",
"billable_time_id": str(billable_time_id)
}
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete billable time entry: {str(e)}"
)