- Introduced a new template for listing scenarios associated with a project. - Added metrics for total, active, draft, and archived scenarios. - Implemented quick actions for creating new scenarios and reviewing project overview. - Enhanced navigation with breadcrumbs for better user experience. refactor: update Opex and Profitability templates for consistency - Changed titles and button labels for clarity in Opex and Profitability templates. - Updated form IDs and action URLs for better alignment with new naming conventions. - Improved navigation links to include scenario and project overviews. test: add integration tests for Opex calculations - Created new tests for Opex calculation HTML and JSON flows. - Validated successful calculations and ensured correct data persistence. - Implemented tests for currency mismatch and unsupported frequency scenarios. test: enhance project and scenario route tests - Added tests to verify scenario list rendering and calculator shortcuts. - Ensured scenario detail pages link back to the portfolio correctly. - Validated project detail pages show associated scenarios accurately.
338 lines
10 KiB
Python
338 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 dependencies import (
|
|
get_pricing_metadata,
|
|
get_unit_of_work,
|
|
require_any_role,
|
|
require_any_role_html,
|
|
require_project_resource,
|
|
require_project_resource_html,
|
|
require_roles,
|
|
require_roles_html,
|
|
)
|
|
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
|
|
from routes.template_filters import create_templates
|
|
|
|
router = APIRouter(prefix="/projects", tags=["Projects"])
|
|
templates = create_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_html(*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_html(*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_html(*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,
|
|
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
|
project: Project = Depends(require_project_resource_html()),
|
|
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,
|
|
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
|
project: Project = Depends(
|
|
require_project_resource_html(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,
|
|
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
|
project: Project = Depends(
|
|
require_project_resource_html(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,
|
|
)
|