diff --git a/changelog.md b/changelog.md index 8c5d180..272b138 100644 --- a/changelog.md +++ b/changelog.md @@ -10,3 +10,5 @@ - Connected project and scenario routers to new Jinja2 list/detail/edit views with HTML forms and redirects. - Implemented FR-009 client-side enhancements with responsive navigation toggle, mobile-first scenario tables, and shared asset loading across templates. - Added scenario comparison validator, FastAPI comparison endpoint, and comprehensive unit tests to enforce FR-009 validation rules through API errors. +- Delivered a new dashboard experience with `templates/dashboard.html`, dedicated styling, and a FastAPI route supplying real project/scenario metrics via repository helpers. +- Extended repositories with count/recency utilities and added pytest coverage, including a dashboard rendering smoke test validating empty-state messaging. diff --git a/routes/dashboard.py b/routes/dashboard.py index ff73c84..94e3eea 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -1,83 +1,102 @@ from __future__ import annotations -from datetime import datetime, timedelta -from types import SimpleNamespace +from datetime import datetime from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from dependencies import get_unit_of_work -from models import MiningOperationType +from models import ScenarioStatus from services.unit_of_work import UnitOfWork router = APIRouter(tags=["Dashboard"]) templates = Jinja2Templates(directory="templates") -def _load_metrics(_: UnitOfWork) -> dict[str, object]: - today = datetime.utcnow() +def _format_timestamp(moment: datetime | None) -> str | None: + if moment is None: + return None + return moment.strftime("%Y-%m-%d") + + +def _format_timestamp_with_time(moment: datetime | None) -> str | None: + if moment is None: + return None + return moment.strftime("%Y-%m-%d %H:%M") + + +def _load_metrics(uow: UnitOfWork) -> dict[str, object]: + total_projects = uow.projects.count() + active_scenarios = uow.scenarios.count_by_status(ScenarioStatus.ACTIVE) + pending_simulations = uow.scenarios.count_by_status(ScenarioStatus.DRAFT) + last_import_at = uow.financial_inputs.latest_created_at() return { - "total_projects": 12, - "active_scenarios": 7, - "pending_simulations": 3, - "last_import": today.strftime("%Y-%m-%d"), + "total_projects": total_projects, + "active_scenarios": active_scenarios, + "pending_simulations": pending_simulations, + "last_import": _format_timestamp(last_import_at), } -def _load_recent_projects(_: UnitOfWork) -> list[SimpleNamespace]: - now = datetime.utcnow() - return [ - SimpleNamespace( - id=1, - name="Copper Ridge Expansion", - operation_type=MiningOperationType.OPEN_PIT, - updated_at=now - timedelta(days=2), - ), - SimpleNamespace( - id=2, - name="Lithium Basin North", - operation_type=MiningOperationType.UNDERGROUND, - updated_at=now - timedelta(days=5), - ), - SimpleNamespace( - id=3, - name="Nickel Underground Phase II", - operation_type=MiningOperationType.IN_SITU_LEACH, - updated_at=now - timedelta(days=9), - ), - ] +def _load_recent_projects(uow: UnitOfWork) -> list: + return list(uow.projects.recent(limit=5)) -def _load_simulation_updates(_: UnitOfWork) -> list[SimpleNamespace]: - now = datetime.utcnow() - return [ - SimpleNamespace( - title="Monte Carlo Batch #21 completed", - description="1,000 runs processed for Lithium Basin North.", - timestamp=now - timedelta(hours=4), - ), - SimpleNamespace( - title="Scenario validation queued", - description="Copper Ridge Expansion pending validation on new cost inputs.", - timestamp=now - timedelta(days=1, hours=3), - ), - ] +def _load_simulation_updates(uow: UnitOfWork) -> list[dict[str, object]]: + updates: list[dict[str, object]] = [] + scenarios = uow.scenarios.recent(limit=5, with_project=True) + for scenario in scenarios: + project_name = scenario.project.name if scenario.project else f"Project #{scenario.project_id}" + timestamp_label = _format_timestamp_with_time(scenario.updated_at) + updates.append( + { + "title": f"{scenario.name} ยท {scenario.status.value.title()}", + "description": f"Latest update recorded for {project_name}.", + "timestamp": scenario.updated_at, + "timestamp_label": timestamp_label, + } + ) + return updates -def _load_scenario_alerts(_: UnitOfWork) -> list[SimpleNamespace]: - return [ - SimpleNamespace( - title="Variance exceeds threshold", - message="Nickel Underground Phase II deviates 18% from baseline forecast.", - link="/projects/3/view", - ), - SimpleNamespace( - title="Simulation backlog", - message="Lithium Basin North has 2 pending simulation batches.", - link="/projects/2/view", - ), - ] +def _load_scenario_alerts( + request: Request, uow: UnitOfWork +) -> list[dict[str, object]]: + alerts: list[dict[str, object]] = [] + + drafts = uow.scenarios.list_by_status( + ScenarioStatus.DRAFT, limit=3, with_project=True + ) + for scenario in drafts: + project_name = scenario.project.name if scenario.project else f"Project #{scenario.project_id}" + alerts.append( + { + "title": f"Draft scenario: {scenario.name}", + "message": f"{project_name} has a scenario awaiting validation.", + "link": request.url_for( + "projects.view_project", project_id=scenario.project_id + ), + } + ) + + if not alerts: + archived = uow.scenarios.list_by_status( + ScenarioStatus.ARCHIVED, limit=3, with_project=True + ) + for scenario in archived: + project_name = scenario.project.name if scenario.project else f"Project #{scenario.project_id}" + alerts.append( + { + "title": f"Archived scenario: {scenario.name}", + "message": f"Review archived scenario insights for {project_name}.", + "link": request.url_for( + "scenarios.view_scenario", scenario_id=scenario.id + ), + } + ) + + return alerts @router.get("/", response_class=HTMLResponse, include_in_schema=False, name="dashboard.home") @@ -90,6 +109,6 @@ def dashboard_home( "metrics": _load_metrics(uow), "recent_projects": _load_recent_projects(uow), "simulation_updates": _load_simulation_updates(uow), - "scenario_alerts": _load_scenario_alerts(uow), + "scenario_alerts": _load_scenario_alerts(request, uow), } return templates.TemplateResponse("dashboard.html", context) diff --git a/services/repositories.py b/services/repositories.py index 8d3dc66..9defd8b 100644 --- a/services/repositories.py +++ b/services/repositories.py @@ -1,13 +1,14 @@ from __future__ import annotations from collections.abc import Iterable +from datetime import datetime from typing import Sequence -from sqlalchemy import select +from sqlalchemy import select, func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload, selectinload -from models import FinancialInput, Project, Scenario, SimulationParameter +from models import FinancialInput, Project, Scenario, ScenarioStatus, SimulationParameter from services.exceptions import EntityConflictError, EntityNotFoundError @@ -23,6 +24,18 @@ class ProjectRepository: stmt = stmt.options(selectinload(Project.scenarios)) return self.session.execute(stmt).scalars().all() + def count(self) -> int: + stmt = select(func.count(Project.id)) + return self.session.execute(stmt).scalar_one() + + def recent(self, limit: int = 5) -> Sequence[Project]: + stmt = ( + select(Project) + .order_by(Project.updated_at.desc()) + .limit(limit) + ) + return self.session.execute(stmt).scalars().all() + def get(self, project_id: int, *, with_children: bool = False) -> Project: stmt = select(Project).where(Project.id == project_id) if with_children: @@ -60,6 +73,39 @@ class ScenarioRepository: ) return self.session.execute(stmt).scalars().all() + def count(self) -> int: + stmt = select(func.count(Scenario.id)) + return self.session.execute(stmt).scalar_one() + + def count_by_status(self, status: ScenarioStatus) -> int: + stmt = select(func.count(Scenario.id)).where(Scenario.status == status) + return self.session.execute(stmt).scalar_one() + + def recent(self, limit: int = 5, *, with_project: bool = False) -> Sequence[Scenario]: + stmt = select(Scenario).order_by( + Scenario.updated_at.desc()).limit(limit) + if with_project: + stmt = stmt.options(joinedload(Scenario.project)) + return self.session.execute(stmt).scalars().all() + + def list_by_status( + self, + status: ScenarioStatus, + *, + limit: int | None = None, + with_project: bool = False, + ) -> Sequence[Scenario]: + stmt = ( + select(Scenario) + .where(Scenario.status == status) + .order_by(Scenario.updated_at.desc()) + ) + if with_project: + stmt = stmt.options(joinedload(Scenario.project)) + if limit is not None: + stmt = stmt.limit(limit) + return self.session.execute(stmt).scalars().all() + def get(self, scenario_id: int, *, with_children: bool = False) -> Scenario: stmt = select(Scenario).where(Scenario.id == scenario_id) if with_children: @@ -119,6 +165,14 @@ class FinancialInputRepository: raise EntityNotFoundError(f"Financial input {input_id} not found") self.session.delete(entity) + def latest_created_at(self) -> datetime | None: + stmt = ( + select(FinancialInput.created_at) + .order_by(FinancialInput.created_at.desc()) + .limit(1) + ) + return self.session.execute(stmt).scalar_one_or_none() + class SimulationParameterRepository: """Persistence operations for SimulationParameter entities.""" diff --git a/templates/Dashboard.html b/templates/Dashboard.html index f1ff951..d843aa0 100644 --- a/templates/Dashboard.html +++ b/templates/Dashboard.html @@ -63,7 +63,7 @@ {{ project.name }}
{{ update.description }}
@@ -106,7 +106,9 @@{{ alert.message }}
+ {% if alert.link %} Review + {% endif %}