Files
PROJECT-CONTORL/backend/app/api/workload/router.py
beabigegg 61fe01cb6b 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>
2025-12-29 01:13:21 +08:00

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