from contextlib import asynccontextmanager from collections.abc import AsyncGenerator from datetime import datetime, timedelta, timezone from fastapi import FastAPI, HTTPException, Query from app.database import ( close_pool, get_history_by_station, get_latest_by_station, init_db, insert_weather_data, ) from app.models import WeatherDataIn, WeatherHistoryResponse, WeatherRecordOut @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Initialize the DB pool and table on startup, tear down on shutdown.""" await init_db() yield await close_pool() app = FastAPI( title="Weather Data API", description="Receive and query weather station observations", version="0.1.0", lifespan=lifespan, ) @app.post("/data", response_model=WeatherRecordOut, status_code=201) async def post_weather_data(payload: WeatherDataIn) -> WeatherRecordOut: """Ingest a weather observation from a station. The server records the time it was received (`received_at`). """ record = await insert_weather_data( station_name=payload.station_name, temperature=payload.temperature, pressure=payload.pressure, relative_humidity=payload.relative_humidity, ) return WeatherRecordOut(**record) @app.get("/data/{station_name}", response_model=WeatherRecordOut) async def get_latest_weather(station_name: str) -> WeatherRecordOut: """Return the most recent observation for the given station.""" record = await get_latest_by_station(station_name) if record is None: raise HTTPException( status_code=404, detail=f"No data found for station '{station_name}'", ) return WeatherRecordOut(**record) @app.get("/data/{station_name}/history", response_model=WeatherHistoryResponse) async def get_weather_history( station_name: str, hours: int | None = Query( None, gt=0, description="Look back this many hours from now (e.g. 24 = last 24 hours). " "Ignored if both start and end are provided.", ), start: datetime | None = Query( None, description="Start of the time range (ISO 8601, UTC). Defaults to unbounded.", ), end: datetime | None = Query( None, description="End of the time range (ISO 8601, UTC). Defaults to now.", ), ) -> WeatherHistoryResponse: """Return all observations for a station within a time range. - Use ``hours`` alone to get the last *N* hours of data. - Use ``start`` and/or ``end`` for a custom range. - Returns an empty ``records`` list when no data matches. """ now = datetime.now(timezone.utc) # Resolve start / end from the query parameters resolved_start: datetime | None = start resolved_end: datetime | None = end if hours is not None: # hours takes precedence for start, but explicit end still wins resolved_start = now - timedelta(hours=hours) if end is None: resolved_end = now # Default end to now if neither hours nor end was given and start was specified if resolved_start is not None and resolved_end is None: resolved_end = now records = await get_history_by_station(station_name, resolved_start, resolved_end) return WeatherHistoryResponse(station_name=station_name, records=records)