From 249991557bf65f5396d3cd49d62a817693192d26 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 28 May 2026 22:39:47 +0000 Subject: [PATCH] examples: /api/examples + /api/examples/summary router --- fire_planner/api/examples.py | 61 +++++++++++++++++++++++++++ fire_planner/app.py | 2 + tests/test_api_examples.py | 81 ++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 fire_planner/api/examples.py create mode 100644 tests/test_api_examples.py diff --git a/fire_planner/api/examples.py b/fire_planner/api/examples.py new file mode 100644 index 0000000..aa8554e --- /dev/null +++ b/fire_planner/api/examples.py @@ -0,0 +1,61 @@ +"""GET /api/examples and /api/examples/summary. + +`/examples` returns the raw FireExample rows (filterable by country, +fi_status, with a sane limit). `/examples/summary` is the aggregated +view the UI / simulator overlay actually wants. +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fire_planner.api.dependencies import get_session +from fire_planner.db import FireExample +from fire_planner.examples.models import Summary +from fire_planner.examples.service import summary_for_country + +router = APIRouter(prefix="/examples", tags=["examples"]) + + +@router.get("") +async def list_examples( + country: Annotated[str | None, Query()] = None, + fi_status: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=500)] = 100, + session: AsyncSession = Depends(get_session), +) -> list[dict[str, object]]: + stmt = select(FireExample) + if country is not None: + stmt = stmt.where(FireExample.country == country) + if fi_status is not None: + stmt = stmt.where(FireExample.fi_status == fi_status) + stmt = stmt.order_by(FireExample.post_date.desc()).limit(limit) + rows = (await session.execute(stmt)).scalars().all() + return [ + { + "reddit_id": r.reddit_id, + "source_sub": r.source_sub, + "post_url": r.post_url, + "post_date": r.post_date.isoformat(), + "country": r.country, + "city": r.city, + "portfolio_gbp": float(r.portfolio_gbp) if r.portfolio_gbp else None, + "annual_exp_gbp": float(r.annual_exp_gbp) if r.annual_exp_gbp else None, + "age": r.age, + "family_size": r.family_size, + "fi_status": r.fi_status, + "is_retired": r.is_retired, + } + for r in rows + ] + + +@router.get("/summary", response_model=Summary) +async def get_summary( + country: Annotated[str, Query(min_length=2)], + session: AsyncSession = Depends(get_session), +) -> Summary: + return await summary_for_country(session, country) diff --git a/fire_planner/app.py b/fire_planner/app.py index cf6164e..76e75f2 100644 --- a/fire_planner/app.py +++ b/fire_planner/app.py @@ -42,6 +42,7 @@ from starlette.types import Scope from fire_planner.api.auth import require_bearer from fire_planner.api.cashflow import router as cashflow_router +from fire_planner.api.examples import router as examples_router from fire_planner.api.goals import router as goals_router from fire_planner.api.income_streams import router as income_streams_router from fire_planner.api.life_events import router as life_events_router @@ -153,6 +154,7 @@ app.include_router(cashflow_router, prefix=_API_PREFIX) app.include_router(spending_profile_router, prefix=_API_PREFIX) app.include_router(simulate_router, prefix=_API_PREFIX) app.include_router(spending_router, prefix=_API_PREFIX) +app.include_router(examples_router, prefix=_API_PREFIX) @app.post( diff --git a/tests/test_api_examples.py b/tests/test_api_examples.py new file mode 100644 index 0000000..0862b25 --- /dev/null +++ b/tests/test_api_examples.py @@ -0,0 +1,81 @@ +"""Tests for /examples and /examples/summary.""" +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import date +from decimal import Decimal + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from fire_planner.api.dependencies import get_session +from fire_planner.app import app +from fire_planner.db import FireExample + + +@pytest_asyncio.fixture +async def client(engine: AsyncEngine, + session: AsyncSession) -> AsyncIterator[AsyncClient]: + factory = async_sessionmaker(engine, expire_on_commit=False) + + async def _override() -> AsyncIterator[AsyncSession]: + async with factory() as s: + yield s + + app.dependency_overrides[get_session] = _override + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() + + +def _make_example(reddit_id: str, + country: str, + portfolio: Decimal) -> FireExample: + return FireExample( + reddit_id=reddit_id, + source_sub="ExpatFIRE", + post_url=f"https://reddit.com/r/ExpatFIRE/comments/{reddit_id}", + post_date=date(2025, 1, 1), + post_title=f"Example {reddit_id}", + country=country, + portfolio_gbp=portfolio, + annual_exp_gbp=Decimal("30000"), + fi_status="FIRE", + llm_model="qwen3-8b", + ) + + +async def test_list_examples_filters_by_country( + client: AsyncClient, session: AsyncSession) -> None: + session.add(_make_example("ph01", "Philippines", Decimal("100000"))) + session.add(_make_example("th01", "Thailand", Decimal("200000"))) + await session.commit() + + resp = await client.get("/examples?country=Philippines") + assert resp.status_code == 200, resp.text + rows = resp.json() + assert len(rows) == 1 + assert rows[0]["reddit_id"] == "ph01" + assert rows[0]["country"] == "Philippines" + assert rows[0]["portfolio_gbp"] == 100000.0 + + +async def test_summary_quartiles( + client: AsyncClient, session: AsyncSession) -> None: + for i, amount in enumerate( + [100_000, 200_000, 300_000, 400_000, 500_000], start=1): + session.add( + _make_example(f"ph{i:02d}", "Philippines", Decimal(str(amount)))) + await session.commit() + + resp = await client.get("/examples/summary?country=Philippines") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["country"] == "Philippines" + assert body["count"] == 5 + portfolio = body["portfolio_gbp"] + assert Decimal(portfolio["median"]) == Decimal("300000.00") + assert Decimal(portfolio["p25"]) == Decimal("200000.00") + assert Decimal(portfolio["p75"]) == Decimal("400000.00")