From 7f5ed6a42d94045b4381a5fd4170cd688cbaa408 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sun, 9 Nov 2025 19:02:36 +0100 Subject: [PATCH] feat: enhance dashboard with new metrics, project and scenario utilities, and comprehensive tests --- changelog.md | 2 + routes/dashboard.py | 137 +++++++++++++++++++--------------- services/repositories.py | 58 +++++++++++++- templates/Dashboard.html | 6 +- tests/test_dashboard_route.py | 73 ++++++++++++++++++ tests/test_repositories.py | 90 +++++++++++++++++++++- 6 files changed, 302 insertions(+), 64 deletions(-) create mode 100644 tests/test_dashboard_route.py 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 }} {{ project.operation_type.value.replace('_', ' ') | title }} - {{ project.updated_at.strftime('%Y-%m-%d') }} + {{ project.updated_at.strftime('%Y-%m-%d') if project.updated_at else 'โ€”' }} {% endfor %} @@ -81,7 +81,7 @@ diff --git a/tests/test_dashboard_route.py b/tests/test_dashboard_route.py new file mode 100644 index 0000000..35a7131 --- /dev/null +++ b/tests/test_dashboard_route.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from config.database import Base +from dependencies import get_unit_of_work +from routes.dashboard import router as dashboard_router +from routes.projects import router as projects_router +from routes.scenarios import router as scenarios_router +from services.unit_of_work import UnitOfWork + + +@pytest.fixture() +def engine() -> Iterator[Engine]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + future=True, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + try: + yield engine + finally: + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +@pytest.fixture() +def session_factory(engine: Engine) -> Iterator[sessionmaker]: + testing_session = sessionmaker( + bind=engine, expire_on_commit=False, future=True) + yield testing_session + + +@pytest.fixture() +def client(session_factory: sessionmaker) -> Iterator[TestClient]: + app = FastAPI() + app.include_router(dashboard_router) + app.include_router(projects_router) + app.include_router(scenarios_router) + + def _override_uow() -> Iterator[UnitOfWork]: + with UnitOfWork(session_factory=session_factory) as uow: + yield uow + + app.dependency_overrides[get_unit_of_work] = _override_uow + + test_client = TestClient(app) + try: + yield test_client + finally: + test_client.close() + + +class TestDashboardRoute: + def test_renders_empty_state(self, client: TestClient) -> None: + response = client.get("/") + assert response.status_code == 200 + html = response.text + + assert "No recent projects" in html + assert "No simulation runs yet" in html + assert "All scenarios look good" in html + assert "โ€”" in html # Last data import placeholder diff --git a/tests/test_repositories.py b/tests/test_repositories.py index 23ae50f..7e00910 100644 --- a/tests/test_repositories.py +++ b/tests/test_repositories.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Iterator +from datetime import datetime, timezone import pytest from sqlalchemy import create_engine @@ -153,4 +154,91 @@ def test_unit_of_work_commit_and_rollback(engine) -> None: with TestingSession() as session: projects = ProjectRepository(session).list() - assert len(projects) == 1 \ No newline at end of file + assert len(projects) == 1 + + +def test_project_repository_count_and_recent(session: Session) -> None: + repo = ProjectRepository(session) + project_alpha = Project(name="Alpha", operation_type=MiningOperationType.OPEN_PIT) + project_bravo = Project(name="Bravo", operation_type=MiningOperationType.UNDERGROUND) + + repo.create(project_alpha) + repo.create(project_bravo) + + project_alpha.updated_at = datetime(2025, 1, 1, tzinfo=timezone.utc) + project_bravo.updated_at = datetime(2025, 1, 2, tzinfo=timezone.utc) + session.flush() + + assert repo.count() == 2 + recent = repo.recent(limit=1) + assert len(recent) == 1 + assert recent[0].name == "Bravo" + + +def test_scenario_repository_counts_and_filters(session: Session) -> None: + project = Project(name="Project", operation_type=MiningOperationType.PLACER) + session.add(project) + session.flush() + + repo = ScenarioRepository(session) + draft = Scenario(name="Draft", project_id=project.id, + status=ScenarioStatus.DRAFT) + active = Scenario(name="Active", project_id=project.id, + status=ScenarioStatus.ACTIVE) + + repo.create(draft) + repo.create(active) + + draft.updated_at = datetime(2025, 1, 1, tzinfo=timezone.utc) + active.updated_at = datetime(2025, 1, 3, tzinfo=timezone.utc) + session.flush() + + assert repo.count() == 2 + assert repo.count_by_status(ScenarioStatus.ACTIVE) == 1 + + recent = repo.recent(limit=1, with_project=True) + assert len(recent) == 1 + assert recent[0].name == "Active" + assert recent[0].project.name == "Project" + + drafts = repo.list_by_status(ScenarioStatus.DRAFT, limit=2, with_project=True) + assert len(drafts) == 1 + assert drafts[0].name == "Draft" + assert drafts[0].project_id == project.id + + +def test_financial_input_repository_latest_created_at(session: Session) -> None: + project = Project(name="Project FI", operation_type=MiningOperationType.OTHER) + scenario = Scenario(name="Scenario FI", project=project) + session.add(project) + session.flush() + + repo = FinancialInputRepository(session) + old_timestamp = datetime(2024, 12, 31, 15, 0) + new_timestamp = datetime(2025, 1, 2, 8, 30) + + repo.bulk_upsert( + [ + FinancialInput( + scenario_id=scenario.id, + name="Legacy Entry", + category=FinancialCategory.OPERATING_EXPENDITURE, + amount=1000, + currency="usd", + created_at=old_timestamp, + updated_at=old_timestamp, + ), + FinancialInput( + scenario_id=scenario.id, + name="New Entry", + category=FinancialCategory.OPERATING_EXPENDITURE, + amount=2000, + currency="usd", + created_at=new_timestamp, + updated_at=new_timestamp, + ), + ] + ) + + latest = repo.latest_created_at() + assert latest == new_timestamp \ No newline at end of file