from typing import Any from sqlalchemy import func, select from sqlalchemy.orm import Session from app.modules.ai_memory.constants import AIMemoryStatus from app.modules.ai_memory.models import AIMemoryEntry from app.modules.audit.models import AuditLog from app.modules.business.constants import DONE_STATUSES, PROJECT_CLOSED_STATUSES, StatusValue from app.modules.business.models import ( LegacySyncRun, Project, ReportPushRun, RiskEvent, WorkReport, WorkTask, ) from app.modules.business.service import serialize_model from app.modules.events.constants import EventStatus from app.modules.events.models import DomainEvent from app.modules.observability.constants import ObservabilityMetricKey from app.modules.observability.service import ObservabilityService from app.modules.reports.constants import ReportPushStatus from app.modules.risk.service import RiskService from app.modules.workflows.constants import WorkflowStatus from app.modules.workflows.models import WorkflowInstance class DashboardService: """Build lightweight operational dashboard data for V2/V3.""" def __init__(self, db: Session): self.db = db self.risks = RiskService(db) def summary(self) -> dict[str, Any]: active_projects = self._count(Project, Project.status.notin_(PROJECT_CLOSED_STATUSES)) open_tasks = self._count(WorkTask, WorkTask.status.notin_(DONE_STATUSES)) open_risk_events = self._count(RiskEvent, RiskEvent.status == StatusValue.OPEN) unassigned_open_risks = self._count( RiskEvent, RiskEvent.status == StatusValue.OPEN, RiskEvent.assigned_to.is_(None), ) failed_push_runs = self._count(ReportPushRun, ReportPushRun.status == ReportPushStatus.FAILED) pending_events = self._count(DomainEvent, DomainEvent.status == EventStatus.PENDING) failed_events = self._count(DomainEvent, DomainEvent.status == EventStatus.FAILED) running_workflows = self._count( WorkflowInstance, WorkflowInstance.status == WorkflowStatus.RUNNING, ) failed_workflows = self._count( WorkflowInstance, WorkflowInstance.status == WorkflowStatus.FAILED, ) active_ai_memory = self._count(AIMemoryEntry, AIMemoryEntry.status == AIMemoryStatus.ACTIVE) heartbeat_summary = ObservabilityService(self.db).heartbeat_summary() latest_reports = self.db.execute( select(WorkReport).order_by(WorkReport.id.desc()).limit(5) ).scalars() latest_push_runs = self.db.execute( select(ReportPushRun).order_by(ReportPushRun.id.desc()).limit(10) ).scalars() latest_sync_runs = self.db.execute( select(LegacySyncRun).order_by(LegacySyncRun.id.desc()).limit(10) ).scalars() latest_audit_logs = self.db.execute( select(AuditLog).order_by(AuditLog.id.desc()).limit(10) ).scalars() risk_summary = self.risks.summary() return { "metrics": { "active_projects": active_projects, "open_tasks": open_tasks, "open_risk_events": open_risk_events, "unassigned_open_risks": unassigned_open_risks, "failed_push_runs": failed_push_runs, "pending_events": pending_events, "failed_events": failed_events, "running_workflows": running_workflows, "failed_workflows": failed_workflows, "active_ai_memory": active_ai_memory, "stale_heartbeats": heartbeat_summary[ObservabilityMetricKey.STALE], "risk_level": risk_summary["risk_level"], "risk_score": float(risk_summary["risk_score"]), }, "risk_counts": { "overdue_tasks": len(risk_summary["overdue_tasks"]), "delayed_projects": len(risk_summary["delayed_projects"]), "over_budget_projects": len(risk_summary["over_budget_projects"]), "fund_risks": len(risk_summary["fund_risks"]), "supplier_risks": len(risk_summary["supplier_risks"]), }, "latest_reports": [serialize_model(item) for item in latest_reports], "latest_push_runs": [serialize_model(item) for item in latest_push_runs], "latest_sync_runs": [serialize_model(item) for item in latest_sync_runs], "latest_audit_logs": [serialize_model(item) for item in latest_audit_logs], } def _count(self, model: type, *conditions: Any) -> int: stmt = select(func.count()).select_from(model) for condition in conditions: stmt = stmt.where(condition) return int(self.db.execute(stmt).scalar() or 0)