feat: implement workload heatmap module
- 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>
This commit is contained in:
217
backend/app/api/workload/router.py
Normal file
217
backend/app/api/workload/router.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user