Files
PROJECT-CONTORL/backend/app/services/health_service.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

406 lines
12 KiB
Python

"""Project health calculation service.
Provides functionality to calculate and retrieve project health metrics
including risk scores, schedule status, and resource status.
"""
import uuid
from datetime import datetime
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from app.models import Project, Task, TaskStatus, Blocker, ProjectHealth, Space
from app.schemas.project_health import (
RiskLevel,
ScheduleStatus,
ResourceStatus,
ProjectHealthResponse,
ProjectHealthWithDetails,
ProjectHealthSummary,
ProjectHealthDashboardResponse,
)
# Constants for health score calculation
BLOCKER_PENALTY_PER_ITEM = 10
BLOCKER_PENALTY_MAX = 30
OVERDUE_PENALTY_PER_ITEM = 5
OVERDUE_PENALTY_MAX = 30
COMPLETION_PENALTY_THRESHOLD = 50
COMPLETION_PENALTY_FACTOR = 0.4
COMPLETION_PENALTY_MAX = 20
# Risk level thresholds
RISK_LOW_THRESHOLD = 80
RISK_MEDIUM_THRESHOLD = 60
RISK_HIGH_THRESHOLD = 40
# Schedule status thresholds
SCHEDULE_AT_RISK_THRESHOLD = 2
# Resource status thresholds
RESOURCE_CONSTRAINED_THRESHOLD = 2
def calculate_health_metrics(db: Session, project: Project) -> Dict[str, Any]:
"""
Calculate health metrics for a project.
Args:
db: Database session
project: Project object to calculate metrics for
Returns:
Dictionary containing:
- health_score: 0-100 integer
- risk_level: low/medium/high/critical
- schedule_status: on_track/at_risk/delayed
- resource_status: adequate/constrained/overloaded
- task_count: Total number of active tasks
- completed_task_count: Number of completed tasks
- blocker_count: Number of unresolved blockers
- overdue_task_count: Number of overdue incomplete tasks
"""
# Fetch active tasks for this project
tasks = db.query(Task).filter(
Task.project_id == project.id,
Task.is_deleted == False
).all()
task_count = len(tasks)
# Count completed tasks
completed_task_count = sum(
1 for task in tasks
if task.status and task.status.is_done
)
# Count overdue tasks (incomplete with past due date)
now = datetime.utcnow()
overdue_task_count = sum(
1 for task in tasks
if task.due_date and task.due_date < now
and not (task.status and task.status.is_done)
)
# Count unresolved blockers from Blocker table
task_ids = [t.id for t in tasks]
blocker_table_count = 0
if task_ids:
blocker_table_count = db.query(Blocker).filter(
Blocker.task_id.in_(task_ids),
Blocker.resolved_at.is_(None)
).count()
# Also count tasks with "Blocked" status (status name contains 'block', case-insensitive)
blocked_status_count = sum(
1 for task in tasks
if task.status and task.status.name and 'block' in task.status.name.lower()
and not task.status.is_done
)
# Total blocker count = blocker table records + tasks with blocked status
blocker_count = blocker_table_count + blocked_status_count
# Calculate completion rate
completion_rate = 0.0
if task_count > 0:
completion_rate = (completed_task_count / task_count) * 100
# Calculate health score (start at 100, subtract penalties)
health_score = 100
# Apply blocker penalty
blocker_penalty = min(blocker_count * BLOCKER_PENALTY_PER_ITEM, BLOCKER_PENALTY_MAX)
health_score -= blocker_penalty
# Apply overdue penalty
overdue_penalty = min(overdue_task_count * OVERDUE_PENALTY_PER_ITEM, OVERDUE_PENALTY_MAX)
health_score -= overdue_penalty
# Apply completion penalty (if below threshold)
if task_count > 0 and completion_rate < COMPLETION_PENALTY_THRESHOLD:
completion_penalty = int(
(COMPLETION_PENALTY_THRESHOLD - completion_rate) * COMPLETION_PENALTY_FACTOR
)
health_score -= min(completion_penalty, COMPLETION_PENALTY_MAX)
# Ensure health score stays within bounds
health_score = max(0, min(100, health_score))
# Determine risk level based on health score
risk_level = _determine_risk_level(health_score)
# Determine schedule status based on overdue count
schedule_status = _determine_schedule_status(overdue_task_count)
# Determine resource status based on blocker count
resource_status = _determine_resource_status(blocker_count)
return {
"health_score": health_score,
"risk_level": risk_level,
"schedule_status": schedule_status,
"resource_status": resource_status,
"task_count": task_count,
"completed_task_count": completed_task_count,
"blocker_count": blocker_count,
"overdue_task_count": overdue_task_count,
}
def _determine_risk_level(health_score: int) -> str:
"""Determine risk level based on health score."""
if health_score >= RISK_LOW_THRESHOLD:
return "low"
elif health_score >= RISK_MEDIUM_THRESHOLD:
return "medium"
elif health_score >= RISK_HIGH_THRESHOLD:
return "high"
else:
return "critical"
def _determine_schedule_status(overdue_task_count: int) -> str:
"""Determine schedule status based on overdue task count."""
if overdue_task_count == 0:
return "on_track"
elif overdue_task_count <= SCHEDULE_AT_RISK_THRESHOLD:
return "at_risk"
else:
return "delayed"
def _determine_resource_status(blocker_count: int) -> str:
"""Determine resource status based on blocker count."""
if blocker_count == 0:
return "adequate"
elif blocker_count <= RESOURCE_CONSTRAINED_THRESHOLD:
return "constrained"
else:
return "overloaded"
def get_or_create_project_health(db: Session, project: Project) -> ProjectHealth:
"""
Get existing project health record or create a new one.
Args:
db: Database session
project: Project object
Returns:
ProjectHealth record
"""
health = db.query(ProjectHealth).filter(
ProjectHealth.project_id == project.id
).first()
if not health:
health = ProjectHealth(
id=str(uuid.uuid4()),
project_id=project.id
)
db.add(health)
return health
def update_project_health(
db: Session,
project: Project,
metrics: Dict[str, Any]
) -> ProjectHealth:
"""
Update project health record with calculated metrics.
Args:
db: Database session
project: Project object
metrics: Calculated health metrics
Returns:
Updated ProjectHealth record
"""
health = get_or_create_project_health(db, project)
health.health_score = metrics["health_score"]
health.risk_level = metrics["risk_level"]
health.schedule_status = metrics["schedule_status"]
health.resource_status = metrics["resource_status"]
return health
def get_project_health(
db: Session,
project_id: str
) -> Optional[ProjectHealthWithDetails]:
"""
Get health information for a single project.
Args:
db: Database session
project_id: Project ID
Returns:
ProjectHealthWithDetails or None if project not found
"""
project = db.query(Project).join(Space, Project.space_id == Space.id).filter(
Project.id == project_id,
Project.is_active == True,
Space.is_active == True
).first()
if not project:
return None
metrics = calculate_health_metrics(db, project)
health = update_project_health(db, project, metrics)
db.commit()
db.refresh(health)
return _build_health_with_details(project, health, metrics)
def get_all_projects_health(
db: Session,
status_filter: Optional[str] = "active"
) -> ProjectHealthDashboardResponse:
"""
Get health information for all projects.
Args:
db: Database session
status_filter: Filter projects by status (default: "active")
Returns:
ProjectHealthDashboardResponse with projects list and summary
"""
query = db.query(Project).join(Space, Project.space_id == Space.id).filter(
Project.is_active == True,
Space.is_active == True
)
if status_filter:
query = query.filter(Project.status == status_filter)
projects = query.all()
projects_health: List[ProjectHealthWithDetails] = []
for project in projects:
metrics = calculate_health_metrics(db, project)
health = update_project_health(db, project, metrics)
project_health = _build_health_with_details(project, health, metrics)
projects_health.append(project_health)
db.commit()
# Calculate summary statistics
summary = _calculate_summary(projects_health)
return ProjectHealthDashboardResponse(
projects=projects_health,
summary=summary
)
def _build_health_with_details(
project: Project,
health: ProjectHealth,
metrics: Dict[str, Any]
) -> ProjectHealthWithDetails:
"""Build ProjectHealthWithDetails from project, health, and metrics."""
return ProjectHealthWithDetails(
id=health.id,
project_id=project.id,
health_score=metrics["health_score"],
risk_level=RiskLevel(metrics["risk_level"]),
schedule_status=ScheduleStatus(metrics["schedule_status"]),
resource_status=ResourceStatus(metrics["resource_status"]),
last_updated=health.last_updated or datetime.utcnow(),
project_title=project.title,
project_status=project.status,
owner_name=project.owner.name if project.owner else None,
space_name=project.space.name if project.space else None,
task_count=metrics["task_count"],
completed_task_count=metrics["completed_task_count"],
blocker_count=metrics["blocker_count"],
overdue_task_count=metrics["overdue_task_count"],
)
def _calculate_summary(
projects_health: List[ProjectHealthWithDetails]
) -> ProjectHealthSummary:
"""Calculate summary statistics for health dashboard.
Thresholds match _determine_risk_level():
- healthy (low risk): >= 80
- at_risk (medium risk): 60-79
- high_risk (high risk): 40-59
- critical (critical risk): < 40
"""
total_projects = len(projects_health)
# Use consistent thresholds with risk_level calculation
healthy_count = sum(1 for p in projects_health if p.health_score >= RISK_LOW_THRESHOLD)
at_risk_count = sum(1 for p in projects_health if RISK_MEDIUM_THRESHOLD <= p.health_score < RISK_LOW_THRESHOLD)
high_risk_count = sum(1 for p in projects_health if RISK_HIGH_THRESHOLD <= p.health_score < RISK_MEDIUM_THRESHOLD)
critical_count = sum(1 for p in projects_health if p.health_score < RISK_HIGH_THRESHOLD)
average_health_score = 0.0
if total_projects > 0:
average_health_score = sum(p.health_score for p in projects_health) / total_projects
projects_with_blockers = sum(1 for p in projects_health if p.blocker_count > 0)
projects_delayed = sum(
1 for p in projects_health
if p.schedule_status == ScheduleStatus.DELAYED
)
return ProjectHealthSummary(
total_projects=total_projects,
healthy_count=healthy_count,
at_risk_count=at_risk_count,
high_risk_count=high_risk_count,
critical_count=critical_count,
average_health_score=round(average_health_score, 1),
projects_with_blockers=projects_with_blockers,
projects_delayed=projects_delayed,
)
class HealthService:
"""
Service class for project health operations.
Provides a class-based interface for health calculations,
following the service pattern used in the codebase.
"""
def __init__(self, db: Session):
"""Initialize HealthService with database session."""
self.db = db
def calculate_metrics(self, project: Project) -> Dict[str, Any]:
"""Calculate health metrics for a project."""
return calculate_health_metrics(self.db, project)
def get_project_health(self, project_id: str) -> Optional[ProjectHealthWithDetails]:
"""Get health information for a single project."""
return get_project_health(self.db, project_id)
def get_dashboard(
self,
status_filter: Optional[str] = "active"
) -> ProjectHealthDashboardResponse:
"""Get health dashboard for all projects."""
return get_all_projects_health(self.db, status_filter)
def refresh_project_health(self, project: Project) -> ProjectHealth:
"""Refresh and persist health data for a project."""
metrics = calculate_health_metrics(self.db, project)
health = update_project_health(self.db, project, metrics)
self.db.commit()
self.db.refresh(health)
return health