feat: Enhance project and scenario creation with monitoring metrics
- 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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
262
tests/test_reporting.py
Normal 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
|
||||
@@ -12,7 +12,6 @@ from services.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_access_token,
|
||||
decode_refresh_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user