feat: Enhance project and scenario creation with monitoring metrics
Some checks failed
CI / lint (push) Failing after 1m14s
CI / test (push) Has been skipped
CI / build (push) Has been skipped

- Added monitoring metrics for project creation success and error handling in `ProjectRepository`.
- Implemented similar monitoring for scenario creation in `ScenarioRepository`.
- Refactored `run_monte_carlo` function in `simulation.py` to include timing and success/error metrics.
- Introduced new CSS styles for headers, alerts, and navigation buttons in `main.css` and `projects.css`.
- Created a new JavaScript file for navigation logic to handle chevron buttons.
- Updated HTML templates to include new navigation buttons and improved styling for buttons.
- Added tests for reporting service and routes to ensure proper functionality and access control.
- Removed unused imports and optimized existing test files for better clarity and performance.
This commit is contained in:
2025-11-12 10:36:24 +01:00
parent f68321cd04
commit ce9c174b53
61 changed files with 2124 additions and 308 deletions

View File

@@ -3,8 +3,10 @@ from __future__ import annotations
from collections.abc import Callable, Iterator
import pytest
import pytest_asyncio
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
@@ -19,6 +21,7 @@ from routes.projects import router as projects_router
from routes.scenarios import router as scenarios_router
from routes.imports import router as imports_router
from routes.exports import router as exports_router
from routes.reports import router as reports_router
from services.importers import ImportIngestionService
from services.unit_of_work import UnitOfWork
from services.session import AuthSession, SessionTokens
@@ -56,6 +59,7 @@ def app(session_factory: sessionmaker) -> FastAPI:
application.include_router(scenarios_router)
application.include_router(imports_router)
application.include_router(exports_router)
application.include_router(reports_router)
def _override_uow() -> Iterator[UnitOfWork]:
with UnitOfWork(session_factory=session_factory) as uow:
@@ -108,6 +112,13 @@ def client(app: FastAPI) -> Iterator[TestClient]:
test_client.close()
@pytest_asyncio.fixture()
async def async_client(app: FastAPI) -> AsyncClient:
return AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
)
@pytest.fixture()
def unit_of_work_factory(session_factory: sessionmaker) -> Callable[[], UnitOfWork]:
def _factory() -> UnitOfWork:

View File

@@ -284,3 +284,110 @@ class TestLogoutFlow:
set_cookie_header = response.headers.get("set-cookie") or ""
assert "calminer_access_token=" in set_cookie_header
assert "Max-Age=0" in set_cookie_header or "expires=" in set_cookie_header.lower()
class TestLoginFlowEndToEnd:
def test_get_login_form_renders(self, client: TestClient) -> None:
response = client.get("/login")
assert response.status_code == 200
assert "login-form" in response.text
assert "username" in response.text
def test_unauthenticated_root_redirects_to_login(self, client: TestClient) -> None:
# Temporarily override to anonymous session
app = cast(FastAPI, client.app)
original_override = app.dependency_overrides.get(get_auth_session)
app.dependency_overrides[get_auth_session] = lambda: AuthSession.anonymous(
)
try:
response = client.get("/", follow_redirects=False)
assert response.status_code == 303
assert response.headers.get(
"location") == "http://testserver/login"
finally:
if original_override is not None:
app.dependency_overrides[get_auth_session] = original_override
else:
app.dependency_overrides.pop(get_auth_session, None)
def test_login_success_redirects_to_dashboard_and_sets_session(
self, client: TestClient, db_session: Session
) -> None:
password = "TestP@ss123"
user = User(
email="e2e@example.com",
username="e2euser",
password_hash=hash_password(password),
is_active=True,
)
db_session.add(user)
db_session.commit()
# Override to anonymous for login
app = cast(FastAPI, client.app)
app.dependency_overrides[get_auth_session] = lambda: AuthSession.anonymous(
)
try:
login_response = client.post(
"/login",
data={"username": "e2euser", "password": password},
follow_redirects=False,
)
assert login_response.status_code == 303
assert login_response.headers.get(
"location") == "http://testserver/"
set_cookie_header = login_response.headers.get("set-cookie", "")
assert "calminer_access_token=" in set_cookie_header
# Now with cookies, GET / should show dashboard
dashboard_response = client.get("/")
assert dashboard_response.status_code == 200
assert "Dashboard" in dashboard_response.text or "metrics" in dashboard_response.text
finally:
app.dependency_overrides.pop(get_auth_session, None)
def test_logout_redirects_to_login_and_clears_session(self, client: TestClient) -> None:
# Assuming authenticated from conftest
logout_response = client.get("/logout", follow_redirects=False)
assert logout_response.status_code == 303
location = logout_response.headers.get("location")
assert location and "login" in location
set_cookie_header = logout_response.headers.get("set-cookie", "")
assert "calminer_access_token=" in set_cookie_header
assert "Max-Age=0" in set_cookie_header or "expires=" in set_cookie_header.lower()
# After logout, GET / should redirect to login
app = cast(FastAPI, client.app)
app.dependency_overrides[get_auth_session] = lambda: AuthSession.anonymous(
)
try:
root_response = client.get("/", follow_redirects=False)
assert root_response.status_code == 303
assert root_response.headers.get(
"location") == "http://testserver/login"
finally:
app.dependency_overrides.pop(get_auth_session, None)
def test_login_inactive_user_shows_error(self, client: TestClient, db_session: Session) -> None:
user = User(
email="inactive@example.com",
username="inactiveuser",
password_hash=hash_password("TestP@ss123"),
is_active=False,
)
db_session.add(user)
db_session.commit()
app = cast(FastAPI, client.app)
app.dependency_overrides[get_auth_session] = lambda: AuthSession.anonymous(
)
try:
response = client.post(
"/login",
data={"username": "inactiveuser", "password": "TestP@ss123"},
follow_redirects=False,
)
assert response.status_code == 400
assert "Account is inactive" in response.text
finally:
app.dependency_overrides.pop(get_auth_session, None)

View File

@@ -8,7 +8,6 @@ from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from models import Project, Scenario, ScenarioStatus
from services.unit_of_work import UnitOfWork
def _seed_projects(session: Session) -> None:

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from fastapi.testclient import TestClient
from models.project import MiningOperationType, Project
from models.scenario import Scenario, ScenarioStatus
def test_project_import_preview_and_commit_flow(

View File

@@ -1,16 +1,12 @@
from __future__ import annotations
from io import BytesIO
import pandas as pd
import pytest
from fastapi.testclient import TestClient
from models import (
MiningOperationType,
Project,
Scenario,
ScenarioStatus,
)
from models.import_export_log import ImportExportLog

View File

@@ -8,7 +8,6 @@ import pytest
from services.importers import ImportResult, load_project_imports, load_scenario_imports
from schemas.imports import ProjectImportRow, ScenarioImportRow
from models.project import MiningOperationType
def test_load_project_imports_from_csv() -> None:

262
tests/test_reporting.py Normal file
View File

@@ -0,0 +1,262 @@
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from unittest.mock import Mock
from models import Project, Scenario, FinancialInput
from models.metadata import CostBucket, ResourceType
from services.reporting import (
ReportingService,
ReportFilters,
IncludeOptions,
ScenarioReport,
ScenarioFinancialTotals,
ScenarioDeterministicMetrics,
)
from routes.reports import router as reports_router
class TestReportingService:
def test_build_project_summary_context(self, unit_of_work_factory):
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario = Scenario(project_id=project.id,
name="Test Scenario", status="draft")
uow.scenarios.create(scenario)
uow.commit()
service = ReportingService(uow)
request = Mock()
request.url_for = Mock(return_value="/api/reports/projects/1")
filters = ReportFilters()
include = IncludeOptions()
context = service.build_project_summary_context(
project, filters, include, 500, (5.0, 50.0, 95.0), request
)
assert "project" in context
assert context["scenario_count"] == 1
assert "aggregates" in context
assert "scenarios" in context
assert context["title"] == f"Project Summary · {project.name}"
def test_build_scenario_comparison_context(self, unit_of_work_factory):
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario1 = Scenario(project_id=project.id,
name="Scenario 1", status="draft")
scenario2 = Scenario(project_id=project.id,
name="Scenario 2", status="active")
uow.scenarios.create(scenario1)
uow.scenarios.create(scenario2)
uow.commit()
service = ReportingService(uow)
request = Mock()
request.url_for = Mock(
return_value="/api/reports/projects/1/comparison")
include = IncludeOptions()
scenarios = [scenario1, scenario2]
context = service.build_scenario_comparison_context(
project, scenarios, include, 500, (5.0, 50.0, 95.0), request
)
assert "project" in context
assert "scenarios" in context
assert "comparison" in context
assert context["title"] == f"Scenario Comparison · {project.name}"
def test_build_scenario_distribution_context(self, unit_of_work_factory):
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario = Scenario(project_id=project.id,
name="Test Scenario", status="draft")
uow.scenarios.create(scenario)
uow.commit()
service = ReportingService(uow)
request = Mock()
request.url_for = Mock(
return_value="/api/reports/scenarios/1/distribution")
include = IncludeOptions()
context = service.build_scenario_distribution_context(
scenario, include, 500, (5.0, 50.0, 95.0), request
)
assert "scenario" in context
assert "summary" in context
assert "metrics" in context
assert "monte_carlo" in context
assert context["title"] == f"Scenario Distribution · {scenario.name}"
def test_scenario_report_to_dict_with_enum_status(self, unit_of_work_factory):
"""Test that to_dict handles enum status values correctly."""
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario = Scenario(
project_id=project.id,
name="Test Scenario",
status="draft", # Stored as string
primary_resource="diesel" # Stored as string
)
uow.scenarios.create(scenario)
uow.commit()
# Create a mock scenario report
totals = ScenarioFinancialTotals(
currency="USD",
inflows=1000.0,
outflows=500.0,
net=500.0,
by_category={}
)
deterministic = ScenarioDeterministicMetrics(
currency="USD",
discount_rate=0.1,
compounds_per_year=1,
npv=100.0,
irr=0.15,
payback_period=2.5,
notes=[]
)
report = ScenarioReport(
scenario=scenario,
totals=totals,
deterministic=deterministic,
monte_carlo=None
)
result = report.to_dict()
assert result["scenario"]["status"] == "draft" # type: ignore
# type: ignore
assert result["scenario"]["primary_resource"] == "diesel"
assert result["financials"]["net"] == 500.0 # type: ignore
assert result["metrics"]["npv"] == 100.0 # type: ignore
def test_project_summary_with_scenario_ids_filter(self, unit_of_work_factory):
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario1 = Scenario(project_id=project.id,
name="Scenario 1", status="active")
scenario2 = Scenario(project_id=project.id,
name="Scenario 2", status="draft")
uow.scenarios.create(scenario1)
uow.scenarios.create(scenario2)
uow.commit()
service = ReportingService(uow)
# Test filtering by scenario IDs
filters = ReportFilters(scenario_ids={scenario1.id})
result = service.project_summary(
project, filters=filters, include=IncludeOptions(),
iterations=100, percentiles=(5.0, 50.0, 95.0)
)
assert result["scenario_count"] == 1 # type: ignore
# type: ignore
# type: ignore
assert result["scenarios"][0]["scenario"]["name"] == "Scenario 1"
class TestReportingRoutes:
def test_project_summary_route(self, client: TestClient, unit_of_work_factory):
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario = Scenario(project_id=project.id,
name="Test Scenario", status="draft")
uow.scenarios.create(scenario)
uow.commit()
response = client.get(f"/reports/projects/{project.id}")
assert response.status_code == 200
data = response.json()
assert "project" in data
assert data["scenario_count"] == 1
def test_project_summary_html_route(self, client: TestClient, unit_of_work_factory):
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario = Scenario(project_id=project.id,
name="Test Scenario", status="draft")
uow.scenarios.create(scenario)
uow.commit()
response = client.get(f"/reports/projects/{project.id}/ui")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Test Project" in response.text
def test_scenario_comparison_route(self, client: TestClient, unit_of_work_factory):
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario1 = Scenario(project_id=project.id,
name="Scenario 1", status="draft")
scenario2 = Scenario(project_id=project.id,
name="Scenario 2", status="active")
uow.scenarios.create(scenario1)
uow.scenarios.create(scenario2)
uow.commit()
response = client.get(
f"/reports/projects/{project.id}/scenarios/compare?scenario_ids={scenario1.id}&scenario_ids={scenario2.id}"
)
assert response.status_code == 200
data = response.json()
assert "project" in data
assert "scenarios" in data
assert "comparison" in data
def test_scenario_distribution_route(self, client: TestClient, unit_of_work_factory):
with unit_of_work_factory() as uow:
project = Project(name="Test Project", location="Test Location")
uow.projects.create(project)
scenario = Scenario(project_id=project.id,
name="Test Scenario", status="draft")
uow.scenarios.create(scenario)
uow.commit()
response = client.get(
f"/reports/scenarios/{scenario.id}/distribution")
assert response.status_code == 200
data = response.json()
assert "scenario" in data
assert "summary" in data
assert "monte_carlo" in data
def test_unauthorized_access(self, client: TestClient):
# Create a new client without authentication
from fastapi import FastAPI
from routes.reports import router as reports_router
app = FastAPI()
app.include_router(reports_router)
from fastapi.testclient import TestClient
unauth_client = TestClient(app)
response = unauth_client.get("/reports/projects/1")
assert response.status_code == 401
def test_project_not_found(self, client: TestClient):
response = client.get("/reports/projects/999")
assert response.status_code == 404
def test_scenario_not_found(self, client: TestClient):
response = client.get("/reports/scenarios/999/distribution")
assert response.status_code == 404

View File

@@ -12,7 +12,6 @@ from services.security import (
create_access_token,
create_refresh_token,
decode_access_token,
decode_refresh_token,
hash_password,
verify_password,
)