from __future__ import annotations from datetime import date 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_unit_of_work from models import ResourceType, Scenario, ScenarioStatus from schemas.scenario import ( ScenarioComparisonRequest, ScenarioComparisonResponse, ScenarioCreate, ScenarioRead, ScenarioUpdate, ) from services.exceptions import ( EntityConflictError, EntityNotFoundError, ScenarioValidationError, ) 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], ) def list_scenarios_for_project( project_id: int, uow: UnitOfWork = Depends(get_unit_of_work) ) -> List[ScenarioRead]: try: uow.projects.get(project_id) except EntityNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc scenarios = uow.scenarios.list_for_project(project_id) return [_to_read_model(scenario) for scenario in scenarios] @router.post( "/projects/{project_id}/scenarios/compare", response_model=ScenarioComparisonResponse, status_code=status.HTTP_200_OK, ) def compare_scenarios( project_id: int, payload: ScenarioComparisonRequest, uow: UnitOfWork = Depends(get_unit_of_work), ) -> ScenarioComparisonResponse: try: uow.projects.get(project_id) except EntityNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) ) from exc try: scenarios = uow.validate_scenarios_for_comparison(payload.scenario_ids) if any(scenario.project_id != project_id for scenario in scenarios): raise ScenarioValidationError( code="SCENARIO_PROJECT_MISMATCH", message="Selected scenarios do not belong to the same project.", scenario_ids=[ scenario.id for scenario in scenarios if scenario.id is not None ], ) except EntityNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) ) from exc except ScenarioValidationError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail={ "code": exc.code, "message": exc.message, "scenario_ids": list(exc.scenario_ids or []), }, ) from exc return ScenarioComparisonResponse( project_id=project_id, scenarios=[_to_read_model(scenario) for scenario in scenarios], ) @router.post( "/projects/{project_id}/scenarios", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED, ) def create_scenario_for_project( project_id: int, payload: ScenarioCreate, uow: UnitOfWork = Depends(get_unit_of_work), ) -> ScenarioRead: try: uow.projects.get(project_id) except EntityNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc scenario = Scenario(project_id=project_id, **payload.model_dump()) try: created = uow.scenarios.create(scenario) except EntityConflictError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc return _to_read_model(created) @router.get("/scenarios/{scenario_id}", response_model=ScenarioRead) def get_scenario( scenario_id: int, uow: UnitOfWork = Depends(get_unit_of_work) ) -> ScenarioRead: 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 return _to_read_model(scenario) @router.put("/scenarios/{scenario_id}", response_model=ScenarioRead) def update_scenario( scenario_id: int, payload: ScenarioUpdate, uow: UnitOfWork = Depends(get_unit_of_work), ) -> ScenarioRead: 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 update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(scenario, field, value) uow.flush() return _to_read_model(scenario) @router.delete("/scenarios/{scenario_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_scenario( scenario_id: int, uow: UnitOfWork = Depends(get_unit_of_work) ) -> None: try: uow.scenarios.delete(scenario_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 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( request, "scenarios/form.html", { "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( request, "scenarios/form.html", { "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 ) scenario_metrics = { "financial_count": len(financial_inputs), "parameter_count": len(simulation_parameters), "currency": scenario.currency, "primary_resource": scenario.primary_resource.value.replace('_', ' ').title() if scenario.primary_resource else None, } return templates.TemplateResponse( request, "scenarios/detail.html", { "project": project, "scenario": scenario, "scenario_metrics": scenario_metrics, "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( request, "scenarios/form.html", { "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, )