Files
PROJECT-CONTORL/backend/app/api/workload/router.py
beabigegg 55f85d0d3c feat: implement soft delete, task editing fixes, and UI improvements
Backend:
- Add soft delete for spaces and projects (is_active flag)
- Add status_id and assignee_id to TaskUpdate schema
- Fix task PATCH endpoint to update status and assignee
- Add validation for assignee_id and status_id in task updates
- Fix health service to count tasks with "Blocked" status as blockers
- Filter out deleted spaces/projects from health dashboard
- Add workload cache invalidation on assignee changes

Frontend:
- Add delete confirmation dialogs for spaces and projects
- Fix UserSelect to display selected user name (valueName prop)
- Fix task detail modal to refresh data after save
- Enforce 2-level subtask depth limit in UI
- Fix timezone bug in date formatting (use local timezone)
- Convert NotificationBell from Tailwind to inline styles
- Add i18n translations for health, workload, settings pages
- Add parent_task_id to Task interface across components

OpenSpec:
- Archive add-delete-capability change

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:32:13 +08:00

226 lines
6.5 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"
),
hide_empty: bool = Query(
True,
description="Hide users with no tasks assigned for the week"
),
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 (only use cache for default hide_empty=True)
cached = None
if hide_empty:
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,
hide_empty=hide_empty,
)
# Cache the result (only cache when hide_empty=True, the default)
if hide_empty:
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