Files
enclava/backend/app/api/v1/budgets.py
2025-08-19 09:50:15 +02:00

675 lines
22 KiB
Python

"""
Budget management endpoints
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete, func
from datetime import datetime, timedelta
from enum import Enum
from app.db.database import get_db
from app.models.budget import Budget
from app.models.user import User
from app.models.usage_tracking import UsageTracking
from app.core.security import get_current_user
from app.services.permission_manager import require_permission
from app.services.audit_service import log_audit_event
from app.core.logging import get_logger
logger = get_logger(__name__)
router = APIRouter()
# Enums
class BudgetType(str, Enum):
TOKENS = "tokens"
DOLLARS = "dollars"
REQUESTS = "requests"
class PeriodType(str, Enum):
HOURLY = "hourly"
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
YEARLY = "yearly"
# Pydantic models
class BudgetCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
budget_type: BudgetType
limit_amount: float = Field(..., gt=0)
period_type: PeriodType
user_id: Optional[str] = None # Admin can set budgets for other users
api_key_id: Optional[str] = None # Budget can be linked to specific API key
is_enabled: bool = True
alert_threshold_percent: float = Field(80.0, ge=0, le=100)
allowed_resources: List[str] = Field(default_factory=list)
metadata: dict = Field(default_factory=dict)
class BudgetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
limit_amount: Optional[float] = Field(None, gt=0)
period_type: Optional[PeriodType] = None
is_enabled: Optional[bool] = None
alert_threshold_percent: Optional[float] = Field(None, ge=0, le=100)
allowed_resources: Optional[List[str]] = None
metadata: Optional[dict] = None
class BudgetResponse(BaseModel):
id: str
name: str
description: Optional[str] = None
budget_type: str
limit_amount: float
period_type: str
period_start: datetime
period_end: datetime
current_usage: float
usage_percentage: float
is_enabled: bool
alert_threshold_percent: float
user_id: Optional[str] = None
api_key_id: Optional[str] = None
allowed_resources: List[str]
metadata: dict
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class BudgetListResponse(BaseModel):
budgets: List[BudgetResponse]
total: int
page: int
size: int
class BudgetUsageResponse(BaseModel):
budget_id: str
current_usage: float
limit_amount: float
usage_percentage: float
remaining_amount: float
period_start: datetime
period_end: datetime
is_exceeded: bool
days_remaining: int
projected_usage: Optional[float] = None
usage_history: List[dict] = Field(default_factory=list)
class BudgetAlertResponse(BaseModel):
budget_id: str
budget_name: str
alert_type: str # "warning", "critical", "exceeded"
current_usage: float
limit_amount: float
usage_percentage: float
message: str
# Budget CRUD endpoints
@router.get("/", response_model=BudgetListResponse)
async def list_budgets(
page: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=100),
user_id: Optional[str] = Query(None),
budget_type: Optional[BudgetType] = Query(None),
is_enabled: Optional[bool] = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""List budgets with pagination and filtering"""
# Check permissions - users can view their own budgets
if user_id and int(user_id) != current_user['id']:
require_permission(current_user.get("permissions", []), "platform:budgets:read")
elif not user_id:
require_permission(current_user.get("permissions", []), "platform:budgets:read")
# If no user_id specified and user doesn't have admin permissions, show only their budgets
if not user_id and "platform:budgets:read" not in current_user.get("permissions", []):
user_id = current_user['id']
# Build query
query = select(Budget)
# Apply filters
if user_id:
query = query.where(Budget.user_id == (int(user_id) if isinstance(user_id, str) else user_id))
if budget_type:
query = query.where(Budget.budget_type == budget_type.value)
if is_enabled is not None:
query = query.where(Budget.is_enabled == is_enabled)
# Get total count
count_query = select(func.count(Budget.id))
# Apply same filters to count query
if user_id:
count_query = count_query.where(Budget.user_id == (int(user_id) if isinstance(user_id, str) else user_id))
if budget_type:
count_query = count_query.where(Budget.budget_type == budget_type.value)
if is_enabled is not None:
count_query = count_query.where(Budget.is_enabled == is_enabled)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Apply pagination
offset = (page - 1) * size
query = query.offset(offset).limit(size).order_by(Budget.created_at.desc())
# Execute query
result = await db.execute(query)
budgets = result.scalars().all()
# Calculate current usage for each budget
budget_responses = []
for budget in budgets:
usage = await _calculate_budget_usage(db, budget)
budget_data = BudgetResponse.model_validate(budget)
budget_data.current_usage = usage
budget_data.usage_percentage = (usage / budget.limit_amount * 100) if budget.limit_amount > 0 else 0
budget_responses.append(budget_data)
# Log audit event
await log_audit_event(
db=db,
user_id=current_user['id'],
action="list_budgets",
resource_type="budget",
details={"page": page, "size": size, "filters": {"user_id": user_id, "budget_type": budget_type, "is_enabled": is_enabled}}
)
return BudgetListResponse(
budgets=budget_responses,
total=total,
page=page,
size=size
)
@router.get("/{budget_id}", response_model=BudgetResponse)
async def get_budget(
budget_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get budget by ID"""
# Get budget
query = select(Budget).where(Budget.id == budget_id)
result = await db.execute(query)
budget = result.scalar_one_or_none()
if not budget:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Budget not found"
)
# Check permissions - users can view their own budgets
if budget.user_id != current_user['id']:
require_permission(current_user.get("permissions", []), "platform:budgets:read")
# Calculate current usage
usage = await _calculate_budget_usage(db, budget)
# Build response
budget_data = BudgetResponse.model_validate(budget)
budget_data.current_usage = usage
budget_data.usage_percentage = (usage / budget.limit_amount * 100) if budget.limit_amount > 0 else 0
# Log audit event
await log_audit_event(
db=db,
user_id=current_user['id'],
action="get_budget",
resource_type="budget",
resource_id=budget_id
)
return budget_data
@router.post("/", response_model=BudgetResponse)
async def create_budget(
budget_data: BudgetCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Create a new budget"""
# Check permissions
require_permission(current_user.get("permissions", []), "platform:budgets:create")
# If user_id not specified, use current user
target_user_id = budget_data.user_id or current_user['id']
# If setting budget for another user, need admin permissions
if int(target_user_id) != current_user['id'] if isinstance(target_user_id, str) else target_user_id != current_user['id']:
require_permission(current_user.get("permissions", []), "platform:budgets:admin")
# Calculate period start and end
now = datetime.utcnow()
period_start, period_end = _calculate_period_bounds(now, budget_data.period_type)
# Create budget
new_budget = Budget(
name=budget_data.name,
description=budget_data.description,
budget_type=budget_data.budget_type.value,
limit_amount=budget_data.limit_amount,
period_type=budget_data.period_type.value,
period_start=period_start,
period_end=period_end,
user_id=target_user_id,
api_key_id=budget_data.api_key_id,
is_enabled=budget_data.is_enabled,
alert_threshold_percent=budget_data.alert_threshold_percent,
allowed_resources=budget_data.allowed_resources,
metadata=budget_data.metadata
)
db.add(new_budget)
await db.commit()
await db.refresh(new_budget)
# Build response
budget_response = BudgetResponse.model_validate(new_budget)
budget_response.current_usage = 0.0
budget_response.usage_percentage = 0.0
# Log audit event
await log_audit_event(
db=db,
user_id=current_user['id'],
action="create_budget",
resource_type="budget",
resource_id=str(new_budget.id),
details={"name": budget_data.name, "budget_type": budget_data.budget_type, "limit_amount": budget_data.limit_amount}
)
logger.info(f"Budget created: {new_budget.name} by {current_user['username']}")
return budget_response
@router.put("/{budget_id}", response_model=BudgetResponse)
async def update_budget(
budget_id: str,
budget_data: BudgetUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update budget"""
# Get budget
query = select(Budget).where(Budget.id == budget_id)
result = await db.execute(query)
budget = result.scalar_one_or_none()
if not budget:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Budget not found"
)
# Check permissions - users can update their own budgets
if budget.user_id != current_user['id']:
require_permission(current_user.get("permissions", []), "platform:budgets:update")
# Store original values for audit
original_values = {
"name": budget.name,
"limit_amount": budget.limit_amount,
"is_enabled": budget.is_enabled
}
# Update budget fields
update_data = budget_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(budget, field, value)
# Recalculate period if period_type changed
if "period_type" in update_data:
period_start, period_end = _calculate_period_bounds(datetime.utcnow(), budget.period_type)
budget.period_start = period_start
budget.period_end = period_end
await db.commit()
await db.refresh(budget)
# Calculate current usage
usage = await _calculate_budget_usage(db, budget)
# Build response
budget_response = BudgetResponse.model_validate(budget)
budget_response.current_usage = usage
budget_response.usage_percentage = (usage / budget.limit_amount * 100) if budget.limit_amount > 0 else 0
# Log audit event
await log_audit_event(
db=db,
user_id=current_user['id'],
action="update_budget",
resource_type="budget",
resource_id=budget_id,
details={
"updated_fields": list(update_data.keys()),
"before_values": original_values,
"after_values": {k: getattr(budget, k) for k in update_data.keys()}
}
)
logger.info(f"Budget updated: {budget.name} by {current_user['username']}")
return budget_response
@router.delete("/{budget_id}")
async def delete_budget(
budget_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete budget"""
# Get budget
query = select(Budget).where(Budget.id == budget_id)
result = await db.execute(query)
budget = result.scalar_one_or_none()
if not budget:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Budget not found"
)
# Check permissions - users can delete their own budgets
if budget.user_id != current_user['id']:
require_permission(current_user.get("permissions", []), "platform:budgets:delete")
# Delete budget
await db.delete(budget)
await db.commit()
# Log audit event
await log_audit_event(
db=db,
user_id=current_user['id'],
action="delete_budget",
resource_type="budget",
resource_id=budget_id,
details={"name": budget.name}
)
logger.info(f"Budget deleted: {budget.name} by {current_user['username']}")
return {"message": "Budget deleted successfully"}
@router.get("/{budget_id}/usage", response_model=BudgetUsageResponse)
async def get_budget_usage(
budget_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get detailed budget usage information"""
# Get budget
query = select(Budget).where(Budget.id == budget_id)
result = await db.execute(query)
budget = result.scalar_one_or_none()
if not budget:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Budget not found"
)
# Check permissions - users can view their own budget usage
if budget.user_id != current_user['id']:
require_permission(current_user.get("permissions", []), "platform:budgets:read")
# Calculate usage
current_usage = await _calculate_budget_usage(db, budget)
usage_percentage = (current_usage / budget.limit_amount * 100) if budget.limit_amount > 0 else 0
remaining_amount = max(0, budget.limit_amount - current_usage)
is_exceeded = current_usage > budget.limit_amount
# Calculate days remaining in period
now = datetime.utcnow()
days_remaining = max(0, (budget.period_end - now).days)
# Calculate projected usage
projected_usage = None
if days_remaining > 0 and current_usage > 0:
days_elapsed = (now - budget.period_start).days + 1
if days_elapsed > 0:
daily_rate = current_usage / days_elapsed
total_days = (budget.period_end - budget.period_start).days + 1
projected_usage = daily_rate * total_days
# Get usage history (last 30 days)
usage_history = await _get_usage_history(db, budget, days=30)
# Log audit event
await log_audit_event(
db=db,
user_id=current_user['id'],
action="get_budget_usage",
resource_type="budget",
resource_id=budget_id
)
return BudgetUsageResponse(
budget_id=budget_id,
current_usage=current_usage,
limit_amount=budget.limit_amount,
usage_percentage=usage_percentage,
remaining_amount=remaining_amount,
period_start=budget.period_start,
period_end=budget.period_end,
is_exceeded=is_exceeded,
days_remaining=days_remaining,
projected_usage=projected_usage,
usage_history=usage_history
)
@router.get("/{budget_id}/alerts", response_model=List[BudgetAlertResponse])
async def get_budget_alerts(
budget_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get budget alerts"""
# Get budget
query = select(Budget).where(Budget.id == budget_id)
result = await db.execute(query)
budget = result.scalar_one_or_none()
if not budget:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Budget not found"
)
# Check permissions - users can view their own budget alerts
if budget.user_id != current_user['id']:
require_permission(current_user.get("permissions", []), "platform:budgets:read")
# Calculate usage
current_usage = await _calculate_budget_usage(db, budget)
usage_percentage = (current_usage / budget.limit_amount * 100) if budget.limit_amount > 0 else 0
alerts = []
# Check for alerts
if usage_percentage >= 100:
alerts.append(BudgetAlertResponse(
budget_id=budget_id,
budget_name=budget.name,
alert_type="exceeded",
current_usage=current_usage,
limit_amount=budget.limit_amount,
usage_percentage=usage_percentage,
message=f"Budget '{budget.name}' has been exceeded ({usage_percentage:.1f}% used)"
))
elif usage_percentage >= 90:
alerts.append(BudgetAlertResponse(
budget_id=budget_id,
budget_name=budget.name,
alert_type="critical",
current_usage=current_usage,
limit_amount=budget.limit_amount,
usage_percentage=usage_percentage,
message=f"Budget '{budget.name}' is critically high ({usage_percentage:.1f}% used)"
))
elif usage_percentage >= budget.alert_threshold_percent:
alerts.append(BudgetAlertResponse(
budget_id=budget_id,
budget_name=budget.name,
alert_type="warning",
current_usage=current_usage,
limit_amount=budget.limit_amount,
usage_percentage=usage_percentage,
message=f"Budget '{budget.name}' has reached alert threshold ({usage_percentage:.1f}% used)"
))
return alerts
# Helper functions
async def _calculate_budget_usage(db: AsyncSession, budget: Budget) -> float:
"""Calculate current usage for a budget"""
# Build base query
query = select(UsageTracking)
# Filter by time period
query = query.where(
UsageTracking.created_at >= budget.period_start,
UsageTracking.created_at <= budget.period_end
)
# Filter by user or API key
if budget.api_key_id:
query = query.where(UsageTracking.api_key_id == budget.api_key_id)
elif budget.user_id:
query = query.where(UsageTracking.user_id == budget.user_id)
# Calculate usage based on budget type
if budget.budget_type == "tokens":
usage_query = query.with_only_columns(func.sum(UsageTracking.total_tokens))
elif budget.budget_type == "dollars":
usage_query = query.with_only_columns(func.sum(UsageTracking.cost_cents))
elif budget.budget_type == "requests":
usage_query = query.with_only_columns(func.count(UsageTracking.id))
else:
return 0.0
result = await db.execute(usage_query)
usage = result.scalar() or 0
# Convert cents to dollars for dollar budgets
if budget.budget_type == "dollars":
usage = usage / 100.0
return float(usage)
def _calculate_period_bounds(current_time: datetime, period_type: str) -> tuple[datetime, datetime]:
"""Calculate period start and end dates"""
if period_type == "hourly":
start = current_time.replace(minute=0, second=0, microsecond=0)
end = start + timedelta(hours=1) - timedelta(microseconds=1)
elif period_type == "daily":
start = current_time.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1) - timedelta(microseconds=1)
elif period_type == "weekly":
# Start of week (Monday)
days_since_monday = current_time.weekday()
start = current_time.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days_since_monday)
end = start + timedelta(weeks=1) - timedelta(microseconds=1)
elif period_type == "monthly":
start = current_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if start.month == 12:
next_month = start.replace(year=start.year + 1, month=1)
else:
next_month = start.replace(month=start.month + 1)
end = next_month - timedelta(microseconds=1)
elif period_type == "yearly":
start = current_time.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = start.replace(year=start.year + 1) - timedelta(microseconds=1)
else:
# Default to daily
start = current_time.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1) - timedelta(microseconds=1)
return start, end
async def _get_usage_history(db: AsyncSession, budget: Budget, days: int = 30) -> List[dict]:
"""Get usage history for the budget"""
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
# Build query
query = select(
func.date(UsageTracking.created_at).label('date'),
func.sum(UsageTracking.total_tokens).label('tokens'),
func.sum(UsageTracking.cost_cents).label('cost_cents'),
func.count(UsageTracking.id).label('requests')
).where(
UsageTracking.created_at >= start_date,
UsageTracking.created_at <= end_date
)
# Filter by user or API key
if budget.api_key_id:
query = query.where(UsageTracking.api_key_id == budget.api_key_id)
elif budget.user_id:
query = query.where(UsageTracking.user_id == budget.user_id)
query = query.group_by(func.date(UsageTracking.created_at)).order_by(func.date(UsageTracking.created_at))
result = await db.execute(query)
rows = result.fetchall()
history = []
for row in rows:
usage_value = 0
if budget.budget_type == "tokens":
usage_value = row.tokens or 0
elif budget.budget_type == "dollars":
usage_value = (row.cost_cents or 0) / 100.0
elif budget.budget_type == "requests":
usage_value = row.requests or 0
history.append({
"date": row.date.isoformat(),
"usage": usage_value,
"tokens": row.tokens or 0,
"cost_dollars": (row.cost_cents or 0) / 100.0,
"requests": row.requests or 0
})
return history