feat: connect project and scenario routers to new Jinja2 views with forms and error handling

This commit is contained in:
2025-11-09 17:32:23 +01:00
parent 191500aeb7
commit d36611606d
6 changed files with 531 additions and 6 deletions

View File

@@ -2,21 +2,30 @@ from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from dependencies import get_unit_of_work
from models import Project
from models import MiningOperationType, Project
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
from services.exceptions import EntityConflictError, EntityNotFoundError
from services.unit_of_work import UnitOfWork
router = APIRouter(prefix="/projects", tags=["Projects"])
templates = Jinja2Templates(directory="templates")
def _to_read_model(project: Project) -> ProjectRead:
return ProjectRead.model_validate(project)
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(uow: UnitOfWork = Depends(get_unit_of_work)) -> List[ProjectRead]:
projects = uow.projects.list()
@@ -74,3 +83,224 @@ def delete_project(project_id: int, uow: UnitOfWork = Depends(get_unit_of_work))
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get(
"/ui",
response_class=HTMLResponse,
include_in_schema=False,
name="projects.project_list_page",
)
def project_list_page(
request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
) -> HTMLResponse:
projects = uow.projects.list(with_children=True)
for project in projects:
setattr(project, "scenario_count", len(project.scenarios))
return templates.TemplateResponse(
"projects/list.html",
{
"request": request,
"projects": projects,
},
)
@router.get(
"/create",
response_class=HTMLResponse,
include_in_schema=False,
name="projects.create_project_form",
)
def create_project_form(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
"projects/form.html",
{
"request": request,
"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,
name: str = Form(...),
location: str | None = Form(None),
operation_type: str = Form(...),
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
try:
op_type = MiningOperationType(operation_type)
except ValueError as exc:
return templates.TemplateResponse(
"projects/form.html",
{
"request": request,
"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:
uow.projects.create(project)
except EntityConflictError as exc:
return templates.TemplateResponse(
"projects/form.html",
{
"request": request,
"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,
)
return RedirectResponse(
request.url_for("projects.project_list_page"),
status_code=status.HTTP_303_SEE_OTHER,
)
@router.get(
"/{project_id}/view",
response_class=HTMLResponse,
include_in_schema=False,
name="projects.view_project",
)
def view_project(
project_id: int, request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
) -> HTMLResponse:
try:
project = uow.projects.get(project_id, with_children=True)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
scenarios = sorted(project.scenarios, key=lambda s: s.created_at)
return templates.TemplateResponse(
"projects/detail.html",
{
"request": request,
"project": project,
"scenarios": scenarios,
},
)
@router.get(
"/{project_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
name="projects.edit_project_form",
)
def edit_project_form(
project_id: int, request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
) -> HTMLResponse:
try:
project = uow.projects.get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
return templates.TemplateResponse(
"projects/form.html",
{
"request": request,
"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(
project_id: int,
request: Request,
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),
):
try:
project = uow.projects.get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
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 as exc:
return templates.TemplateResponse(
"projects/form.html",
{
"request": request,
"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,
)

View File

@@ -1,22 +1,39 @@
from __future__ import annotations
from datetime import date
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from dependencies import get_unit_of_work
from models import Scenario
from models import ResourceType, Scenario, ScenarioStatus
from schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioUpdate
from services.exceptions import EntityConflictError, EntityNotFoundError
from services.unit_of_work import UnitOfWork
router = APIRouter(tags=["Scenarios"])
templates = Jinja2Templates(directory="templates")
def _to_read_model(scenario: Scenario) -> ScenarioRead:
return ScenarioRead.model_validate(scenario)
def _resource_type_choices() -> list[tuple[str, str]]:
return [
(resource.value, resource.value.replace("_", " ").title())
for resource in ResourceType
]
def _scenario_status_choices() -> list[tuple[str, str]]:
return [
(status.value, status.value.title()) for status in ScenarioStatus
]
@router.get(
"/projects/{project_id}/scenarios",
response_model=List[ScenarioRead],
@@ -101,3 +118,270 @@ def delete_scenario(
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
def _normalise(value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None
def _parse_date(value: str | None) -> date | None:
value = _normalise(value)
if not value:
return None
return date.fromisoformat(value)
def _parse_discount_rate(value: str | None) -> float | None:
value = _normalise(value)
if not value:
return None
try:
return float(value)
except ValueError:
return None
@router.get(
"/projects/{project_id}/scenarios/new",
response_class=HTMLResponse,
include_in_schema=False,
name="scenarios.create_scenario_form",
)
def create_scenario_form(
project_id: int, request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
) -> HTMLResponse:
try:
project = uow.projects.get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
return templates.TemplateResponse(
"scenarios/form.html",
{
"request": request,
"project": project,
"scenario": None,
"scenario_statuses": _scenario_status_choices(),
"resource_types": _resource_type_choices(),
"form_action": request.url_for(
"scenarios.create_scenario_submit", project_id=project_id
),
"cancel_url": request.url_for(
"projects.view_project", project_id=project_id
),
},
)
@router.post(
"/projects/{project_id}/scenarios/new",
include_in_schema=False,
name="scenarios.create_scenario_submit",
)
def create_scenario_submit(
project_id: int,
request: Request,
name: str = Form(...),
description: str | None = Form(None),
status_value: str = Form(ScenarioStatus.DRAFT.value),
start_date: str | None = Form(None),
end_date: str | None = Form(None),
discount_rate: str | None = Form(None),
currency: str | None = Form(None),
primary_resource: str | None = Form(None),
uow: UnitOfWork = Depends(get_unit_of_work),
):
try:
project = uow.projects.get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
try:
status_enum = ScenarioStatus(status_value)
except ValueError:
status_enum = ScenarioStatus.DRAFT
resource_enum = None
if primary_resource:
try:
resource_enum = ResourceType(primary_resource)
except ValueError:
resource_enum = None
currency_value = _normalise(currency)
currency_value = currency_value.upper() if currency_value else None
scenario = Scenario(
project_id=project_id,
name=name.strip(),
description=_normalise(description),
status=status_enum,
start_date=_parse_date(start_date),
end_date=_parse_date(end_date),
discount_rate=_parse_discount_rate(discount_rate),
currency=currency_value,
primary_resource=resource_enum,
)
try:
uow.scenarios.create(scenario)
except EntityConflictError as exc:
return templates.TemplateResponse(
"scenarios/form.html",
{
"request": request,
"project": project,
"scenario": scenario,
"scenario_statuses": _scenario_status_choices(),
"resource_types": _resource_type_choices(),
"form_action": request.url_for(
"scenarios.create_scenario_submit", project_id=project_id
),
"cancel_url": request.url_for(
"projects.view_project", project_id=project_id
),
"error": "Scenario could not be created.",
},
status_code=status.HTTP_409_CONFLICT,
)
return RedirectResponse(
request.url_for("projects.view_project", project_id=project_id),
status_code=status.HTTP_303_SEE_OTHER,
)
@router.get(
"/scenarios/{scenario_id}/view",
response_class=HTMLResponse,
include_in_schema=False,
name="scenarios.view_scenario",
)
def view_scenario(
scenario_id: int, request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
) -> HTMLResponse:
try:
scenario = uow.scenarios.get(scenario_id, with_children=True)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
project = uow.projects.get(scenario.project_id)
financial_inputs = sorted(
scenario.financial_inputs, key=lambda item: item.created_at
)
simulation_parameters = sorted(
scenario.simulation_parameters, key=lambda item: item.created_at
)
return templates.TemplateResponse(
"scenarios/detail.html",
{
"request": request,
"project": project,
"scenario": scenario,
"financial_inputs": financial_inputs,
"simulation_parameters": simulation_parameters,
},
)
@router.get(
"/scenarios/{scenario_id}/edit",
response_class=HTMLResponse,
include_in_schema=False,
name="scenarios.edit_scenario_form",
)
def edit_scenario_form(
scenario_id: int, request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
) -> HTMLResponse:
try:
scenario = uow.scenarios.get(scenario_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
project = uow.projects.get(scenario.project_id)
return templates.TemplateResponse(
"scenarios/form.html",
{
"request": request,
"project": project,
"scenario": scenario,
"scenario_statuses": _scenario_status_choices(),
"resource_types": _resource_type_choices(),
"form_action": request.url_for(
"scenarios.edit_scenario_submit", scenario_id=scenario_id
),
"cancel_url": request.url_for(
"scenarios.view_scenario", scenario_id=scenario_id
),
},
)
@router.post(
"/scenarios/{scenario_id}/edit",
include_in_schema=False,
name="scenarios.edit_scenario_submit",
)
def edit_scenario_submit(
scenario_id: int,
request: Request,
name: str = Form(...),
description: str | None = Form(None),
status_value: str = Form(ScenarioStatus.DRAFT.value),
start_date: str | None = Form(None),
end_date: str | None = Form(None),
discount_rate: str | None = Form(None),
currency: str | None = Form(None),
primary_resource: str | None = Form(None),
uow: UnitOfWork = Depends(get_unit_of_work),
):
try:
scenario = uow.scenarios.get(scenario_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
project = uow.projects.get(scenario.project_id)
scenario.name = name.strip()
scenario.description = _normalise(description)
try:
scenario.status = ScenarioStatus(status_value)
except ValueError:
scenario.status = ScenarioStatus.DRAFT
scenario.start_date = _parse_date(start_date)
scenario.end_date = _parse_date(end_date)
scenario.discount_rate = _parse_discount_rate(discount_rate)
currency_value = _normalise(currency)
scenario.currency = currency_value.upper() if currency_value else None
resource_enum = None
if primary_resource:
try:
resource_enum = ResourceType(primary_resource)
except ValueError:
resource_enum = None
scenario.primary_resource = resource_enum
uow.flush()
return RedirectResponse(
request.url_for("scenarios.view_scenario", scenario_id=scenario_id),
status_code=status.HTTP_303_SEE_OTHER,
)