first commit
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"case_sensitive": False,
|
||||
}
|
||||
|
||||
database_host: str = "localhost"
|
||||
database_port: int = 5432
|
||||
database_user: str = "postgres"
|
||||
database_password: str = "postgres"
|
||||
database_name: str = "weather_data"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return (
|
||||
f"postgresql://{self.database_user}:{self.database_password}"
|
||||
f"@{self.database_host}:{self.database_port}/{self.database_name}"
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
from datetime import datetime
|
||||
|
||||
import asyncpg
|
||||
from app.config import settings
|
||||
|
||||
_pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
"""Return the global connection pool, creating it if necessary."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = await asyncpg.create_pool(
|
||||
settings.database_url,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool() -> None:
|
||||
"""Close the connection pool."""
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create the weather_data table if it doesn't exist."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS weather_data (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
station_name TEXT NOT NULL,
|
||||
temperature DOUBLE PRECISION,
|
||||
pressure DOUBLE PRECISION,
|
||||
relative_humidity DOUBLE PRECISION,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_weather_station_name
|
||||
ON weather_data (station_name, received_at DESC);
|
||||
""")
|
||||
|
||||
|
||||
async def insert_weather_data(
|
||||
station_name: str,
|
||||
temperature: float | None,
|
||||
pressure: float | None,
|
||||
relative_humidity: float | None,
|
||||
) -> dict:
|
||||
"""Insert a weather record and return it."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO weather_data (station_name, temperature, pressure, relative_humidity)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, station_name, temperature, pressure, relative_humidity, received_at
|
||||
""",
|
||||
station_name,
|
||||
temperature,
|
||||
pressure,
|
||||
relative_humidity,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def get_latest_by_station(station_name: str) -> dict | None:
|
||||
"""Return the most recent record for a given station, or None."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, station_name, temperature, pressure, relative_humidity, received_at
|
||||
FROM weather_data
|
||||
WHERE station_name = $1
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
station_name,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_history_by_station(
|
||||
station_name: str,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return all records for a station within the given time range.
|
||||
|
||||
Returns an empty list if no records match.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
conditions = ["station_name = $1"]
|
||||
params: list = [station_name]
|
||||
idx = 2
|
||||
|
||||
if start_time is not None:
|
||||
conditions.append(f"received_at >= ${idx}")
|
||||
params.append(start_time)
|
||||
idx += 1
|
||||
|
||||
if end_time is not None:
|
||||
conditions.append(f"received_at <= ${idx}")
|
||||
params.append(end_time)
|
||||
idx += 1
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, station_name, temperature, pressure, relative_humidity, received_at
|
||||
FROM weather_data
|
||||
WHERE {where_clause}
|
||||
ORDER BY received_at DESC
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
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)
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WeatherDataIn(BaseModel):
|
||||
"""Payload accepted by POST /data."""
|
||||
|
||||
station_name: str = Field(..., description="Name of the weather station")
|
||||
temperature: float | None = Field(None, description="Temperature in Celsius")
|
||||
pressure: float | None = Field(None, description="Atmospheric pressure in hPa")
|
||||
relative_humidity: float | None = Field(
|
||||
None, description="Relative humidity in percent (0-100)"
|
||||
)
|
||||
|
||||
|
||||
class WeatherRecordOut(BaseModel):
|
||||
"""Record returned by the API (includes server-assigned fields)."""
|
||||
|
||||
id: int
|
||||
station_name: str
|
||||
temperature: float | None
|
||||
pressure: float | None
|
||||
relative_humidity: float | None
|
||||
received_at: datetime
|
||||
|
||||
|
||||
class WeatherHistoryResponse(BaseModel):
|
||||
"""Wrapper for history queries — always returns a list (empty if no data)."""
|
||||
|
||||
station_name: str
|
||||
records: list[WeatherRecordOut]
|
||||
Reference in New Issue
Block a user