- Backend (FastAPI):
- Workload heatmap API with load level calculation
- User workload detail endpoint with task breakdown
- Redis caching for workload calculations (1hr TTL)
- Department isolation and access control
- WorkloadSnapshot model for historical data
- Alembic migration for workload_snapshots table
- API Endpoints:
- GET /api/workload/heatmap - Team workload overview
- GET /api/workload/user/{id} - User workload detail
- GET /api/workload/me - Current user workload
- Load Levels:
- normal: <80%, warning: 80-99%, overloaded: >=100%
- Tests:
- 26 unit/API tests
- 15 E2E automated tests
- 77 total tests passing
- OpenSpec:
- add-resource-workload change archived
- resource-management spec updated
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
218 lines
6.2 KiB
Python
218 lines
6.2 KiB
Python
"""Workload API endpoints.
|
|
|
|
Provides endpoints for workload heatmap, user workload details,
|
|
and capacity management.
|
|
"""
|
|
from datetime import date
|
|
from typing import Optional, List
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.database import get_db
|
|
from app.middleware.auth import get_current_user
|
|
from app.models.user import User
|
|
from app.schemas.workload import (
|
|
WorkloadHeatmapResponse,
|
|
UserWorkloadDetail,
|
|
CapacityUpdate,
|
|
UserWorkloadSummary,
|
|
)
|
|
from app.services.workload_service import (
|
|
get_week_bounds,
|
|
get_current_week_start,
|
|
get_workload_heatmap,
|
|
get_user_workload_detail,
|
|
)
|
|
from app.services.workload_cache import (
|
|
get_cached_heatmap,
|
|
set_cached_heatmap,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def check_workload_access(
|
|
current_user: User,
|
|
target_user_id: Optional[str] = None,
|
|
target_user_department_id: Optional[str] = None,
|
|
department_id: Optional[str] = None,
|
|
) -> None:
|
|
"""
|
|
Check if current user has access to view workload data.
|
|
|
|
Raises HTTPException if access is denied.
|
|
"""
|
|
# System admin can access all
|
|
if current_user.is_system_admin:
|
|
return
|
|
|
|
# If querying specific user, must be self
|
|
# (Phase 1: only self access for non-admin users)
|
|
if target_user_id and target_user_id != current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied: Cannot view other users' workload",
|
|
)
|
|
|
|
# If querying by department, must be same department
|
|
if department_id and department_id != current_user.department_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied: Cannot view other departments' workload",
|
|
)
|
|
|
|
|
|
def filter_accessible_users(
|
|
current_user: User,
|
|
user_ids: Optional[List[str]] = None,
|
|
) -> Optional[List[str]]:
|
|
"""
|
|
Filter user IDs to only those accessible by current user.
|
|
Returns None if user can access all (system admin).
|
|
"""
|
|
# System admin can access all
|
|
if current_user.is_system_admin:
|
|
return user_ids
|
|
|
|
# Regular user can only see themselves
|
|
if user_ids:
|
|
# Filter to only accessible users
|
|
accessible = [uid for uid in user_ids if uid == current_user.id]
|
|
if not accessible:
|
|
return [current_user.id] # Default to self if no accessible users
|
|
return accessible
|
|
else:
|
|
# No filter specified, return only self
|
|
return [current_user.id]
|
|
|
|
|
|
@router.get("/heatmap", response_model=WorkloadHeatmapResponse)
|
|
async def get_heatmap(
|
|
week_start: Optional[date] = Query(
|
|
None,
|
|
description="Start of week (ISO date, defaults to current Monday)"
|
|
),
|
|
department_id: Optional[str] = Query(
|
|
None,
|
|
description="Filter by department ID"
|
|
),
|
|
user_ids: Optional[str] = Query(
|
|
None,
|
|
description="Comma-separated list of user IDs to include"
|
|
),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Get workload heatmap for users.
|
|
|
|
Returns workload summaries for users showing:
|
|
- allocated_hours: Total estimated hours from tasks due this week
|
|
- capacity_hours: User's weekly capacity
|
|
- load_percentage: Percentage of capacity used
|
|
- load_level: normal (<80%), warning (80-99%), overloaded (>=100%)
|
|
"""
|
|
# Parse user_ids if provided
|
|
parsed_user_ids = None
|
|
if user_ids:
|
|
parsed_user_ids = [uid.strip() for uid in user_ids.split(",") if uid.strip()]
|
|
|
|
# Check department access
|
|
if department_id:
|
|
check_workload_access(current_user, department_id=department_id)
|
|
|
|
# Filter user_ids based on access
|
|
accessible_user_ids = filter_accessible_users(current_user, parsed_user_ids)
|
|
|
|
# Normalize week_start
|
|
if week_start is None:
|
|
week_start = get_current_week_start()
|
|
else:
|
|
week_start = get_week_bounds(week_start)[0]
|
|
|
|
week_start, week_end = get_week_bounds(week_start)
|
|
|
|
# Try cache first
|
|
cached = get_cached_heatmap(week_start, department_id, accessible_user_ids)
|
|
if cached:
|
|
return WorkloadHeatmapResponse(
|
|
week_start=week_start,
|
|
week_end=week_end,
|
|
users=cached,
|
|
)
|
|
|
|
# Calculate from database
|
|
summaries = get_workload_heatmap(
|
|
db=db,
|
|
week_start=week_start,
|
|
department_id=department_id,
|
|
user_ids=accessible_user_ids,
|
|
)
|
|
|
|
# Cache the result
|
|
set_cached_heatmap(week_start, summaries, department_id, accessible_user_ids)
|
|
|
|
return WorkloadHeatmapResponse(
|
|
week_start=week_start,
|
|
week_end=week_end,
|
|
users=summaries,
|
|
)
|
|
|
|
|
|
@router.get("/user/{user_id}", response_model=UserWorkloadDetail)
|
|
async def get_user_workload(
|
|
user_id: str,
|
|
week_start: Optional[date] = Query(
|
|
None,
|
|
description="Start of week (ISO date, defaults to current Monday)"
|
|
),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Get detailed workload for a specific user.
|
|
|
|
Returns:
|
|
- Workload summary (same as heatmap)
|
|
- List of tasks contributing to the workload
|
|
"""
|
|
# Check access
|
|
check_workload_access(current_user, target_user_id=user_id)
|
|
|
|
# Calculate workload detail
|
|
detail = get_user_workload_detail(db, user_id, week_start)
|
|
|
|
if detail is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found",
|
|
)
|
|
|
|
return detail
|
|
|
|
|
|
@router.get("/me", response_model=UserWorkloadDetail)
|
|
async def get_my_workload(
|
|
week_start: Optional[date] = Query(
|
|
None,
|
|
description="Start of week (ISO date, defaults to current Monday)"
|
|
),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Get workload for the current authenticated user.
|
|
|
|
Convenience endpoint that doesn't require specifying user ID.
|
|
"""
|
|
detail = get_user_workload_detail(db, current_user.id, week_start)
|
|
|
|
if detail is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to calculate workload",
|
|
)
|
|
|
|
return detail
|