diff --git a/changelog.md b/changelog.md index a436c92..3f0f5e3 100644 --- a/changelog.md +++ b/changelog.md @@ -7,3 +7,4 @@ - Introduced repository and unit-of-work helpers for projects, scenarios, financial inputs, and simulation parameters to support service-layer operations. - Added SQLite-backed pytest coverage for repository and unit-of-work behaviours to validate persistence interactions. - Exposed project and scenario CRUD APIs with validated schemas and integrated them into the FastAPI application. +- Connected project and scenario routers to new Jinja2 list/detail/edit views with HTML forms and redirects. diff --git a/routes/projects.py b/routes/projects.py index a362e1f..fb937c6 100644 --- a/routes/projects.py +++ b/routes/projects.py @@ -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, + ) diff --git a/routes/scenarios.py b/routes/scenarios.py index c34d81d..465534d 100644 --- a/routes/scenarios.py +++ b/routes/scenarios.py @@ -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, + ) diff --git a/services/repositories.py b/services/repositories.py index 21c10da..8d3dc66 100644 --- a/services/repositories.py +++ b/services/repositories.py @@ -5,7 +5,7 @@ from typing import Sequence from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, selectinload from models import FinancialInput, Project, Scenario, SimulationParameter from services.exceptions import EntityConflictError, EntityNotFoundError @@ -17,8 +17,10 @@ class ProjectRepository: def __init__(self, session: Session) -> None: self.session = session - def list(self) -> Sequence[Project]: + def list(self, *, with_children: bool = False) -> Sequence[Project]: stmt = select(Project).order_by(Project.created_at) + if with_children: + stmt = stmt.options(selectinload(Project.scenarios)) return self.session.execute(stmt).scalars().all() def get(self, project_id: int, *, with_children: bool = False) -> Project: diff --git a/templates/projects/form.html b/templates/projects/form.html index 1b03c3b..4740399 100644 --- a/templates/projects/form.html +++ b/templates/projects/form.html @@ -19,6 +19,10 @@ + {% if error %} +