"""Scenario CRUD + projection read. Mixed surface: - GET /scenarios list (filter by kind) - GET /scenarios/{id} single - POST /scenarios create user scenario - PATCH /scenarios/{id} update user scenario - DELETE /scenarios/{id} delete user scenario (cartesian protected) - GET /scenarios/{id}/projection latest MC run + per-year fan """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from fire_planner.api.dependencies import get_session from fire_planner.api.schemas import ( ProjectionPoint, ScenarioCreate, ScenarioOut, ScenarioPatch, ScenarioProjection, ) from fire_planner.db import McRun, ProjectionYearly, Scenario router = APIRouter(prefix="/scenarios", tags=["scenarios"]) @router.get("", response_model=list[ScenarioOut]) async def list_scenarios( kind: str | None = None, session: AsyncSession = Depends(get_session), ) -> list[Scenario]: """List all scenarios. Filter `kind=user` or `kind=cartesian`.""" stmt = select(Scenario).order_by(Scenario.id) if kind is not None: stmt = stmt.where(Scenario.kind == kind) rows = (await session.execute(stmt)).scalars().all() return list(rows) @router.get("/{scenario_id}", response_model=ScenarioOut) async def get_scenario( scenario_id: int, session: AsyncSession = Depends(get_session), ) -> Scenario: scen = await session.get(Scenario, scenario_id) if scen is None: raise HTTPException(status_code=404, detail="Scenario not found") return scen @router.post( "", response_model=ScenarioOut, status_code=201, ) async def create_scenario( payload: ScenarioCreate, session: AsyncSession = Depends(get_session), ) -> Scenario: """Create a user scenario. Cartesian scenarios come from the engine, not the API.""" if payload.parent_scenario_id is not None: parent = await session.get(Scenario, payload.parent_scenario_id) if parent is None: raise HTTPException(status_code=400, detail="parent_scenario_id not found") scen = Scenario( external_id=f"user-{uuid.uuid4().hex[:12]}", kind="user", name=payload.name, description=payload.description, parent_scenario_id=payload.parent_scenario_id, jurisdiction=payload.jurisdiction, strategy=payload.strategy, leave_uk_year=payload.leave_uk_year, glide_path=payload.glide_path, spending_gbp=payload.spending_gbp, horizon_years=payload.horizon_years, nw_seed_gbp=payload.nw_seed_gbp, savings_per_year_gbp=payload.savings_per_year_gbp, config_json=payload.config_json, ) session.add(scen) await session.commit() await session.refresh(scen) return scen @router.patch( "/{scenario_id}", response_model=ScenarioOut, ) async def patch_scenario( scenario_id: int, payload: ScenarioPatch, session: AsyncSession = Depends(get_session), ) -> Scenario: scen = await session.get(Scenario, scenario_id) if scen is None: raise HTTPException(status_code=404, detail="Scenario not found") if scen.kind != "user": raise HTTPException(status_code=400, detail="Cannot patch cartesian scenarios — they're auto-generated") updates = payload.model_dump(exclude_unset=True) for k, v in updates.items(): setattr(scen, k, v) await session.commit() await session.refresh(scen) return scen @router.delete( "/{scenario_id}", status_code=204, response_model=None, ) async def delete_scenario( scenario_id: int, session: AsyncSession = Depends(get_session), ) -> None: scen = await session.get(Scenario, scenario_id) if scen is None: raise HTTPException(status_code=404, detail="Scenario not found") if scen.kind != "user": raise HTTPException( status_code=400, detail="Cannot delete cartesian scenarios — they re-appear on recompute", ) await session.execute(delete(Scenario).where(Scenario.id == scenario_id)) await session.commit() @router.get("/{scenario_id}/projection", response_model=ScenarioProjection) async def get_scenario_projection( scenario_id: int, session: AsyncSession = Depends(get_session), ) -> ScenarioProjection: """Latest MC run for this scenario + the per-year fan series.""" scen = await session.get(Scenario, scenario_id) if scen is None: raise HTTPException(status_code=404, detail="Scenario not found") run = (await session.execute( select(McRun).where(McRun.scenario_id == scenario_id).order_by( McRun.run_at.desc()).limit(1))).scalar_one_or_none() if run is None: raise HTTPException(status_code=404, detail="No MC runs persisted for this scenario yet") yearly_rows = (await session.execute( select(ProjectionYearly).where(ProjectionYearly.mc_run_id == run.id).order_by( ProjectionYearly.year_idx))).scalars().all() return ScenarioProjection( scenario_id=scen.id, external_id=scen.external_id, mc_run_id=run.id, run_at=run.run_at, n_paths=run.n_paths, success_rate=run.success_rate, p10_ending_gbp=run.p10_ending_gbp, p50_ending_gbp=run.p50_ending_gbp, p90_ending_gbp=run.p90_ending_gbp, median_lifetime_tax_gbp=run.median_lifetime_tax_gbp, median_years_to_ruin=run.median_years_to_ruin, yearly=[ProjectionPoint.model_validate(y) for y in yearly_rows], )