examples: /api/examples + /api/examples/summary router
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
9e14909ca6
commit
249991557b
3 changed files with 144 additions and 0 deletions
61
fire_planner/api/examples.py
Normal file
61
fire_planner/api/examples.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -42,6 +42,7 @@ from starlette.types import Scope
|
||||||
|
|
||||||
from fire_planner.api.auth import require_bearer
|
from fire_planner.api.auth import require_bearer
|
||||||
from fire_planner.api.cashflow import router as cashflow_router
|
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.goals import router as goals_router
|
||||||
from fire_planner.api.income_streams import router as income_streams_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
|
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(spending_profile_router, prefix=_API_PREFIX)
|
||||||
app.include_router(simulate_router, prefix=_API_PREFIX)
|
app.include_router(simulate_router, prefix=_API_PREFIX)
|
||||||
app.include_router(spending_router, prefix=_API_PREFIX)
|
app.include_router(spending_router, prefix=_API_PREFIX)
|
||||||
|
app.include_router(examples_router, prefix=_API_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
|
|
|
||||||
81
tests/test_api_examples.py
Normal file
81
tests/test_api_examples.py
Normal file
|
|
@ -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")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue