Files
calminer/routes/projects.py
zwitschi ce9c174b53
Some checks failed
CI / lint (push) Failing after 1m14s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
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.
2025-11-12 10:36:24 +01:00

331 lines
10 KiB
Python

from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from dependencies import (
get_pricing_metadata,
get_unit_of_work,
require_any_role,
require_project_resource,
require_roles,
)
from models import MiningOperationType, Project, ScenarioStatus, User
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
from services.exceptions import EntityConflictError
from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork
router = APIRouter(prefix="/projects", tags=["Projects"])
templates = Jinja2Templates(directory="templates")
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
MANAGE_ROLES = ("project_manager", "admin")
def _to_read_model(project: Project) -> ProjectRead:
return ProjectRead.model_validate(project)
def _require_project_repo(uow: UnitOfWork):
if not uow.projects:
raise RuntimeError("Project repository not initialised")
return uow.projects
def _operation_type_choices() -> list[tuple[str, str]]:
return [
(op.value, op.name.replace("_", " ").title()) for op in MiningOperationType
]
@router.get("", response_model=List[ProjectRead])
def list_projects(
_: User = Depends(require_any_role(*READ_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> List[ProjectRead]:
projects = _require_project_repo(uow).list()
return [_to_read_model(project) for project in projects]
@router.post("", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
def create_project(
payload: ProjectCreate,
_: User = Depends(require_roles(*MANAGE_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
metadata: PricingMetadata = Depends(get_pricing_metadata),
) -> ProjectRead:
project = Project(**payload.model_dump())
try:
created = _require_project_repo(uow).create(project)
except EntityConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
default_settings = uow.ensure_default_pricing_settings(
metadata=metadata).settings
uow.set_project_pricing_settings(created, default_settings)
return _to_read_model(created)
@router.get(
"/ui",
response_class=HTMLResponse,
include_in_schema=False,
name="projects.project_list_page",
)
def project_list_page(
request: Request,
_: User = Depends(require_any_role(*READ_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
projects = _require_project_repo(uow).list(with_children=True)
for project in projects:
setattr(project, "scenario_count", len(project.scenarios))
return templates.TemplateResponse(
request,
"projects/list.html",
{
"projects": projects,
},
)
@router.get(
"/create",
response_class=HTMLResponse,
include_in_schema=False,
name="projects.create_project_form",
)
def create_project_form(
request: Request, _: User = Depends(require_roles(*MANAGE_ROLES))
) -> HTMLResponse:
return templates.TemplateResponse(
request,
"projects/form.html",
{
"project": None,
"operation_types": _operation_type_choices(),
"form_action": request.url_for("projects.create_project_submit"),
"cancel_url": request.url_for("projects.project_list_page"),
},
)
@router.post(
"/create",
include_in_schema=False,
name="projects.create_project_submit",
)
def create_project_submit(
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
name: str = Form(...),
location: str | None = Form(None),
operation_type: str = Form(...),
description: str | None = Form(None),
uow: UnitOfWork = Depends(get_unit_of_work),
metadata: PricingMetadata = Depends(get_pricing_metadata),
):
def _normalise(value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None
try:
op_type = MiningOperationType(operation_type)
except ValueError:
return templates.TemplateResponse(
request,
"projects/form.html",
{
"project": None,
"operation_types": _operation_type_choices(),
"form_action": request.url_for("projects.create_project_submit"),
"cancel_url": request.url_for("projects.project_list_page"),
"error": "Invalid operation type.",
},
status_code=status.HTTP_400_BAD_REQUEST,
)
project = Project(
name=name.strip(),
location=_normalise(location),
operation_type=op_type,
description=_normalise(description),
)
try:
created = _require_project_repo(uow).create(project)
except EntityConflictError:
return templates.TemplateResponse(
request,
"projects/form.html",
{
"project": project,
"operation_types": _operation_type_choices(),
"form_action": request.url_for("projects.create_project_submit"),
"cancel_url": request.url_for("projects.project_list_page"),
"error": "Project with this name already exists.",
},
status_code=status.HTTP_409_CONFLICT,
)
default_settings = uow.ensure_default_pricing_settings(
metadata=metadata).settings
uow.set_project_pricing_settings(created, default_settings)
return RedirectResponse(
request.url_for("projects.project_list_page"),
status_code=status.HTTP_303_SEE_OTHER,
)
@router.get("/{project_id}", response_model=ProjectRead)
def get_project(project: Project = Depends(require_project_resource())) -> ProjectRead:
return _to_read_model(project)
@router.put("/{project_id}", response_model=ProjectRead)
def update_project(
payload: ProjectUpdate,
project: Project = Depends(
require_project_resource(require_manage=True)
),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> ProjectRead:
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(project, field, value)
uow.flush()
return _to_read_model(project)
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(
project: Project = Depends(require_project_resource(require_manage=True)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> None:
_require_project_repo(uow).delete(project.id)
@router.get(
"/{project_id}/view",
response_class=HTMLResponse,
include_in_schema=False,
name="projects.view_project",
)
def view_project(
request: Request,
project: Project = Depends(require_project_resource()),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
project = _require_project_repo(uow).get(project.id, with_children=True)
scenarios = sorted(project.scenarios, key=lambda s: s.created_at)
scenario_stats = {
"total": len(scenarios),
"active": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.ACTIVE),
"draft": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.DRAFT),
"archived": sum(1 for scenario in scenarios if scenario.status == ScenarioStatus.ARCHIVED),
"latest_update": max(
(scenario.updated_at for scenario in scenarios if scenario.updated_at),
default=None,
),
}
return templates.TemplateResponse(
request,
"projects/detail.html",
{
"project": project,
"scenarios": scenarios,
"scenario_stats": scenario_stats,
},
)
@router.get(
"/{project_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
name="projects.edit_project_form",
)
def edit_project_form(
request: Request,
project: Project = Depends(
require_project_resource(require_manage=True)
),
) -> HTMLResponse:
return templates.TemplateResponse(
request,
"projects/form.html",
{
"project": project,
"operation_types": _operation_type_choices(),
"form_action": request.url_for(
"projects.edit_project_submit", project_id=project.id
),
"cancel_url": request.url_for(
"projects.view_project", project_id=project.id
),
},
)
@router.post(
"/{project_id}/edit",
include_in_schema=False,
name="projects.edit_project_submit",
)
def edit_project_submit(
request: Request,
project: Project = Depends(
require_project_resource(require_manage=True)
),
name: str = Form(...),
location: str | None = Form(None),
operation_type: str | None = Form(None),
description: str | None = Form(None),
uow: UnitOfWork = Depends(get_unit_of_work),
):
def _normalise(value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None
project.name = name.strip()
project.location = _normalise(location)
if operation_type:
try:
project.operation_type = MiningOperationType(operation_type)
except ValueError:
return templates.TemplateResponse(
request,
"projects/form.html",
{
"project": project,
"operation_types": _operation_type_choices(),
"form_action": request.url_for(
"projects.edit_project_submit", project_id=project.id
),
"cancel_url": request.url_for(
"projects.view_project", project_id=project.id
),
"error": "Invalid operation type.",
},
status_code=status.HTTP_400_BAD_REQUEST,
)
project.description = _normalise(description)
uow.flush()
return RedirectResponse(
request.url_for("projects.view_project", project_id=project.id),
status_code=status.HTTP_303_SEE_OTHER,
)