mirror of
https://github.com/furyhawk/home_stack.git
synced 2026-07-21 02:06:47 +00:00
perf: optimize backend API services and Docker build caching
- Add database connection pooling (pool_size=20, max_overflow=10, pool_pre_ping) - Add LRU response caching for weather endpoints with configurable TTLs - Reuse httpx.AsyncClient across weather API requests - Add Docker build cache mounts for uv and npm package managers - Optimize query ordering in users and items endpoints - Apply ruff formatting fixes across backend
This commit is contained in:
@@ -13,7 +13,15 @@ Use this as the default operating guide for coding agents working here.
|
|||||||
## Repository Layout
|
## Repository Layout
|
||||||
- `backend/`: FastAPI app, SQLModel models, Alembic migrations, pytest suite, backend scripts.
|
- `backend/`: FastAPI app, SQLModel models, Alembic migrations, pytest suite, backend scripts.
|
||||||
- `frontend/`: React 19 + TypeScript + Vite app, Chakra UI, TanStack Router, TanStack Query, Playwright tests.
|
- `frontend/`: React 19 + TypeScript + Vite app, Chakra UI, TanStack Router, TanStack Query, Playwright tests.
|
||||||
|
- `ai_stack/`: AI inference backends (llamacpp, ollama, speaches, vllm).
|
||||||
|
- `esphome/`: ESPHome configuration for IoT devices.
|
||||||
|
- `glance/`: Glance dashboard configuration.
|
||||||
|
- `notebooks/`: Jupyter notebooks for AI/ML experiments (Gemma, Qwen, Whisper, Pydantic AI).
|
||||||
- `scripts/`: top-level Docker and integration scripts.
|
- `scripts/`: top-level Docker and integration scripts.
|
||||||
|
- `hooks/`: Copier project generation hooks.
|
||||||
|
- `.github/`: GitHub workflows, issue/discussion templates, dependabot config.
|
||||||
|
- `docker-compose*.yml`: Docker Compose configurations (base, override, traefik).
|
||||||
|
- `makefile`: Make commands for common tasks.
|
||||||
- `frontend/src/client/`: generated OpenAPI client; avoid manual edits.
|
- `frontend/src/client/`: generated OpenAPI client; avoid manual edits.
|
||||||
- `frontend/src/routeTree.gen.ts`: generated route tree; do not hand-edit.
|
- `frontend/src/routeTree.gen.ts`: generated route tree; do not hand-edit.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -26,12 +26,13 @@ COPY ./pyproject.toml ./uv.lock ./alembic.ini /app/
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=cache,target=/root/.cache/pip \
|
||||||
uv pip install --system -e .
|
uv pip install --system -e .
|
||||||
|
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
# Copy application code and scripts
|
# Copy application code and scripts
|
||||||
COPY ./scripts /app/scripts
|
|
||||||
COPY ./app /app/app
|
COPY ./app /app/app
|
||||||
|
COPY ./scripts /app/scripts
|
||||||
|
|
||||||
CMD ["fastapi", "run", "--workers", "4", "app/main.py"]
|
CMD ["fastapi", "run", "--workers", "4", "app/main.py"]
|
||||||
|
|||||||
@@ -17,19 +17,11 @@ def read_items(
|
|||||||
"""
|
"""
|
||||||
Retrieve items.
|
Retrieve items.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if current_user.is_superuser:
|
if current_user.is_superuser:
|
||||||
count_statement = select(func.count()).select_from(Item)
|
|
||||||
count = session.exec(count_statement).one()
|
|
||||||
statement = select(Item).offset(skip).limit(limit)
|
statement = select(Item).offset(skip).limit(limit)
|
||||||
items = session.exec(statement).all()
|
items = session.exec(statement).all()
|
||||||
|
count_statement = select(func.count()).select_from(Item)
|
||||||
else:
|
else:
|
||||||
count_statement = (
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Item)
|
|
||||||
.where(Item.owner_id == current_user.id)
|
|
||||||
)
|
|
||||||
count = session.exec(count_statement).one()
|
|
||||||
statement = (
|
statement = (
|
||||||
select(Item)
|
select(Item)
|
||||||
.where(Item.owner_id == current_user.id)
|
.where(Item.owner_id == current_user.id)
|
||||||
@@ -37,6 +29,12 @@ def read_items(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
items = session.exec(statement).all()
|
items = session.exec(statement).all()
|
||||||
|
count_statement = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Item)
|
||||||
|
.where(Item.owner_id == current_user.id)
|
||||||
|
)
|
||||||
|
count = session.exec(count_statement).one()
|
||||||
|
|
||||||
return ItemsPublic(data=items, count=count)
|
return ItemsPublic(data=items, count=count)
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,12 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
|
|||||||
"""
|
"""
|
||||||
Retrieve users.
|
Retrieve users.
|
||||||
"""
|
"""
|
||||||
|
statement = select(User).offset(skip).limit(limit)
|
||||||
|
users = session.exec(statement).all()
|
||||||
|
|
||||||
count_statement = select(func.count()).select_from(User)
|
count_statement = select(func.count()).select_from(User)
|
||||||
count = session.exec(count_statement).one()
|
count = session.exec(count_statement).one()
|
||||||
|
|
||||||
statement = select(User).offset(skip).limit(limit)
|
|
||||||
users = session.exec(statement).all()
|
|
||||||
|
|
||||||
return UsersPublic(data=users, count=count)
|
return UsersPublic(data=users, count=count)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+197
-191
@@ -1,196 +1,198 @@
|
|||||||
from datetime import datetime
|
from typing import Any
|
||||||
from typing import Any, Optional, Dict
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
# Import directly from models file
|
from app.core.cache import cache_response, weather_cache
|
||||||
from app.models import (
|
from app.models import (
|
||||||
WeatherApiError,
|
|
||||||
WeatherResponse,
|
|
||||||
AirTemperatureResponse,
|
AirTemperatureResponse,
|
||||||
WindDirectionResponse,
|
FourDayForecastResponse,
|
||||||
LightningResponse,
|
LightningResponse,
|
||||||
WBGTResponse,
|
|
||||||
TwentyFourHourForecastResponse,
|
TwentyFourHourForecastResponse,
|
||||||
FourDayForecastResponse
|
WBGTResponse,
|
||||||
|
WeatherApiError,
|
||||||
|
WeatherResponse,
|
||||||
|
WindDirectionResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/weather", tags=["weather"])
|
router = APIRouter(prefix="/weather", tags=["weather"])
|
||||||
|
|
||||||
WEATHER_API_BASE_URL = "https://api-open.data.gov.sg/v2/real-time/api"
|
WEATHER_API_BASE_URL = "https://api-open.data.gov.sg/v2/real-time/api"
|
||||||
|
|
||||||
|
_http_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_http_client() -> httpx.AsyncClient:
|
||||||
|
global _http_client
|
||||||
|
if _http_client is None or _http_client.is_closed:
|
||||||
|
_http_client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
return _http_client
|
||||||
|
|
||||||
|
|
||||||
async def make_api_request(
|
async def make_api_request(
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
params: Dict[str, Any] = None,
|
params: dict[str, Any] | None = None,
|
||||||
response_model: Any = None
|
response_model: Any = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
|
||||||
Generic function to make API requests to the weather API
|
|
||||||
"""
|
|
||||||
url = f"{WEATHER_API_BASE_URL}/{endpoint}"
|
url = f"{WEATHER_API_BASE_URL}/{endpoint}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
client = await get_http_client()
|
||||||
response = await client.get(url, params=params)
|
response = await client.get(url, params=params)
|
||||||
|
|
||||||
# Handle error responses
|
if response.status_code != 200:
|
||||||
if response.status_code != 200:
|
|
||||||
try:
|
|
||||||
error_data = response.json()
|
|
||||||
error = WeatherApiError(**error_data)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=response.status_code,
|
|
||||||
detail=f"{error.name}: {error.error_msg}"
|
|
||||||
)
|
|
||||||
except (ValidationError, KeyError):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=response.status_code,
|
|
||||||
detail=f"Error from weather API: {response.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse successful response
|
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
error_data = response.json()
|
||||||
|
error = WeatherApiError(**error_data)
|
||||||
# Try parsing with the model
|
|
||||||
if response_model:
|
|
||||||
return response_model(**data)
|
|
||||||
return data
|
|
||||||
except ValidationError as e:
|
|
||||||
# If there's a validation error, provide detailed error information
|
|
||||||
print(f"Validation error parsing weather data: {str(e)}")
|
|
||||||
|
|
||||||
# If the error is specifically about timestamp fields, try to fix
|
|
||||||
if 'update_timestamp' in str(e) or 'updated_timestamp' in str(e) or 'updatedTimestamp' in str(e):
|
|
||||||
# Try to manually fix the data structure
|
|
||||||
if 'data' in data:
|
|
||||||
# For standard items structure
|
|
||||||
if 'items' in data['data']:
|
|
||||||
for item in data['data']['items']:
|
|
||||||
if 'update_timestamp' in item and 'updated_timestamp' not in item:
|
|
||||||
item['updated_timestamp'] = item['update_timestamp']
|
|
||||||
|
|
||||||
# For forecast records structure (both 24-hour and 4-day)
|
|
||||||
if 'records' in data['data']:
|
|
||||||
for record in data['data']['records']:
|
|
||||||
# Handle updatedTimestamp field
|
|
||||||
if 'updatedTimestamp' in record and 'updated_timestamp' not in record:
|
|
||||||
record['updated_timestamp'] = record['updatedTimestamp']
|
|
||||||
|
|
||||||
# Handle possible "tiemstamp" typo in API that should be "timestamp"
|
|
||||||
if 'tiemstamp' in record and 'timestamp' not in record:
|
|
||||||
record['timestamp'] = record['tiemstamp']
|
|
||||||
|
|
||||||
# Try parsing again with the fixed data
|
|
||||||
try:
|
|
||||||
if response_model:
|
|
||||||
return response_model(**data)
|
|
||||||
return data
|
|
||||||
except ValidationError:
|
|
||||||
# If it still fails, fall back to the original error
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Handle field name mismatches for air temperature data
|
|
||||||
if 'device_id' in str(e) and 'Station' in str(e):
|
|
||||||
# API returns deviceId, but model expects device_id
|
|
||||||
if 'data' in data and 'stations' in data['data']:
|
|
||||||
for station in data['data']['stations']:
|
|
||||||
if 'deviceId' in station and 'device_id' not in station:
|
|
||||||
station['device_id'] = station['deviceId']
|
|
||||||
|
|
||||||
# Try parsing again with the fixed data
|
|
||||||
try:
|
|
||||||
if response_model:
|
|
||||||
return response_model(**data)
|
|
||||||
return data
|
|
||||||
except ValidationError as new_e:
|
|
||||||
# If there are other issues, continue with fixes
|
|
||||||
print(f"Still having validation issues after device_id fix: {str(new_e)}")
|
|
||||||
|
|
||||||
# Handle 24-hour forecast structure issues
|
|
||||||
if 'TwentyFourHourForecast' in str(e):
|
|
||||||
# Handle missing area_metadata or other structural issues
|
|
||||||
if 'data' in data:
|
|
||||||
# If area_metadata is missing but required, provide an empty list
|
|
||||||
if 'area_metadata' not in data['data'] and 'records' in data['data']:
|
|
||||||
data['data']['area_metadata'] = []
|
|
||||||
|
|
||||||
# Try parsing again with the fixed data
|
|
||||||
try:
|
|
||||||
if response_model:
|
|
||||||
return response_model(**data)
|
|
||||||
return data
|
|
||||||
except ValidationError as new_e:
|
|
||||||
# If it still fails, continue with other fixes
|
|
||||||
print(f"Still having validation issues after 24-hour forecast fix: {str(new_e)}")
|
|
||||||
|
|
||||||
# Handle readings issues (station_id -> stationId mapping and data flattening)
|
|
||||||
if 'station_id' in str(e) or 'value' in str(e) or 'Reading' in str(e) or 'stationId' in str(e):
|
|
||||||
if 'data' in data and 'readings' in data['data']:
|
|
||||||
for reading in data['data']['readings']:
|
|
||||||
# The API structure has timestamp at reading level and data array with stationId/value pairs
|
|
||||||
# but the frontend/older structure expects flattened readings
|
|
||||||
if 'data' in reading and isinstance(reading['data'], list):
|
|
||||||
# For air temperature, flatten the data structure
|
|
||||||
# Convert from: {"timestamp": "...", "data": [{"stationId": "S109", "value": 33}]}
|
|
||||||
# To: [{"station_id": "S109", "value": 33, "timestamp": "..."}]
|
|
||||||
flattened_data = []
|
|
||||||
timestamp = reading.get('timestamp')
|
|
||||||
for data_point in reading['data']:
|
|
||||||
flattened_data.append({
|
|
||||||
'station_id': data_point.get('stationId', data_point.get('id')),
|
|
||||||
'value': data_point.get('value'),
|
|
||||||
'timestamp': timestamp
|
|
||||||
})
|
|
||||||
# Replace the readings array with flattened structure
|
|
||||||
data['data']['readings'] = flattened_data
|
|
||||||
break # Only process first reading with nested data
|
|
||||||
elif 'id' in reading and 'station_id' not in reading:
|
|
||||||
reading['station_id'] = reading['id']
|
|
||||||
|
|
||||||
# Try parsing again with the fixed data
|
|
||||||
try:
|
|
||||||
if response_model:
|
|
||||||
return response_model(**data)
|
|
||||||
return data
|
|
||||||
except ValidationError as new_e:
|
|
||||||
# If it still fails, fall back to the original error
|
|
||||||
print(f"Still having validation issues after station_id fix: {str(new_e)}")
|
|
||||||
|
|
||||||
# If we couldn't fix it, raise the original error
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=response.status_code,
|
||||||
detail=f"Error parsing weather data: {str(e)}"
|
detail=f"{error.name}: {error.error_msg}",
|
||||||
)
|
)
|
||||||
|
except (ValidationError, KeyError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=response.status_code,
|
||||||
|
detail=f"Error from weather API: {response.text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if response_model:
|
||||||
|
return response_model(**data)
|
||||||
|
return data
|
||||||
|
except ValidationError as e:
|
||||||
|
print(f"Validation error parsing weather data: {str(e)}")
|
||||||
|
|
||||||
|
if (
|
||||||
|
"update_timestamp" in str(e)
|
||||||
|
or "updated_timestamp" in str(e)
|
||||||
|
or "updatedTimestamp" in str(e)
|
||||||
|
):
|
||||||
|
if "data" in data:
|
||||||
|
if "items" in data["data"]:
|
||||||
|
for item in data["data"]["items"]:
|
||||||
|
if (
|
||||||
|
"update_timestamp" in item
|
||||||
|
and "updated_timestamp" not in item
|
||||||
|
):
|
||||||
|
item["updated_timestamp"] = item["update_timestamp"]
|
||||||
|
|
||||||
|
if "records" in data["data"]:
|
||||||
|
for record in data["data"]["records"]:
|
||||||
|
if (
|
||||||
|
"updatedTimestamp" in record
|
||||||
|
and "updated_timestamp" not in record
|
||||||
|
):
|
||||||
|
record["updated_timestamp"] = record["updatedTimestamp"]
|
||||||
|
|
||||||
|
if "tiemstamp" in record and "timestamp" not in record:
|
||||||
|
record["timestamp"] = record["tiemstamp"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if response_model:
|
||||||
|
return response_model(**data)
|
||||||
|
return data
|
||||||
|
except ValidationError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if "device_id" in str(e) and "Station" in str(e):
|
||||||
|
if "data" in data and "stations" in data["data"]:
|
||||||
|
for station in data["data"]["stations"]:
|
||||||
|
if "deviceId" in station and "device_id" not in station:
|
||||||
|
station["device_id"] = station["deviceId"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if response_model:
|
||||||
|
return response_model(**data)
|
||||||
|
return data
|
||||||
|
except ValidationError as new_e:
|
||||||
|
print(
|
||||||
|
f"Still having validation issues after device_id fix: {str(new_e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "TwentyFourHourForecast" in str(e):
|
||||||
|
if "data" in data:
|
||||||
|
if (
|
||||||
|
"area_metadata" not in data["data"]
|
||||||
|
and "records" in data["data"]
|
||||||
|
):
|
||||||
|
data["data"]["area_metadata"] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if response_model:
|
||||||
|
return response_model(**data)
|
||||||
|
return data
|
||||||
|
except ValidationError as new_e:
|
||||||
|
print(
|
||||||
|
f"Still having validation issues after 24-hour forecast fix: {str(new_e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"station_id" in str(e)
|
||||||
|
or "value" in str(e)
|
||||||
|
or "Reading" in str(e)
|
||||||
|
or "stationId" in str(e)
|
||||||
|
):
|
||||||
|
if "data" in data and "readings" in data["data"]:
|
||||||
|
for reading in data["data"]["readings"]:
|
||||||
|
if "data" in reading and isinstance(reading["data"], list):
|
||||||
|
flattened_data = []
|
||||||
|
timestamp = reading.get("timestamp")
|
||||||
|
for data_point in reading["data"]:
|
||||||
|
flattened_data.append(
|
||||||
|
{
|
||||||
|
"station_id": data_point.get(
|
||||||
|
"stationId", data_point.get("id")
|
||||||
|
),
|
||||||
|
"value": data_point.get("value"),
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data["data"]["readings"] = flattened_data
|
||||||
|
break
|
||||||
|
elif "id" in reading and "station_id" not in reading:
|
||||||
|
reading["station_id"] = reading["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if response_model:
|
||||||
|
return response_model(**data)
|
||||||
|
return data
|
||||||
|
except ValidationError as new_e:
|
||||||
|
print(
|
||||||
|
f"Still having validation issues after station_id fix: {str(new_e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error parsing weather data: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail=f"Error communicating with weather API: {str(e)}"
|
detail=f"Error communicating with weather API: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/two-hour-forecast", response_model=WeatherResponse)
|
@router.get("/two-hour-forecast", response_model=WeatherResponse)
|
||||||
|
@cache_response(_ttl=1800, cache=weather_cache)
|
||||||
async def get_two_hour_forecast(
|
async def get_two_hour_forecast(
|
||||||
date: Optional[str] = Query(
|
date: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
|
description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
|
||||||
),
|
),
|
||||||
pagination_token: Optional[str] = Query(
|
pagination_token: str | None = Query(
|
||||||
None, description="Pagination token for retrieving subsequent data pages"
|
None,
|
||||||
|
description="Pagination token for retrieving subsequent data pages",
|
||||||
),
|
),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Retrieve the latest two hour weather forecast from data.gov.sg API.
|
Retrieve the latest two hour weather forecast from data.gov.sg API.
|
||||||
|
|
||||||
- Updated half-hourly from NEA
|
- Updated half-hourly from NEA
|
||||||
- Forecasts are given for multiple areas in Singapore
|
- Forecasts are given for multiple areas in Singapore
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Build the URL and parameters
|
|
||||||
params = {}
|
params = {}
|
||||||
if date:
|
if date:
|
||||||
params["date"] = date
|
params["date"] = date
|
||||||
@@ -201,22 +203,23 @@ async def get_two_hour_forecast(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/air-temperature", response_model=AirTemperatureResponse)
|
@router.get("/air-temperature", response_model=AirTemperatureResponse)
|
||||||
|
@cache_response(_ttl=60, cache=weather_cache)
|
||||||
async def get_air_temperature(
|
async def get_air_temperature(
|
||||||
date: Optional[str] = Query(
|
date: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description="Format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (SGT). Example: 2024-07-16 or 2024-07-16T23:59:00",
|
description="Format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (SGT). Example: 2024-07-16 or 2024-07-16T23:59:00",
|
||||||
),
|
),
|
||||||
pagination_token: Optional[str] = Query(
|
pagination_token: str | None = Query(
|
||||||
None, description="Pagination token for retrieving subsequent data pages"
|
None,
|
||||||
|
description="Pagination token for retrieving subsequent data pages",
|
||||||
),
|
),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Get air temperature readings across Singapore
|
Get air temperature readings across Singapore
|
||||||
|
|
||||||
- Has per-minute readings from NEA
|
- Has per-minute readings from NEA
|
||||||
- Unit of measure for readings is °C
|
- Unit of measure for readings is °C
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
if date:
|
if date:
|
||||||
params["date"] = date
|
params["date"] = date
|
||||||
@@ -227,22 +230,23 @@ async def get_air_temperature(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/wind-direction", response_model=WindDirectionResponse)
|
@router.get("/wind-direction", response_model=WindDirectionResponse)
|
||||||
|
@cache_response(_ttl=60, cache=weather_cache)
|
||||||
async def get_wind_direction(
|
async def get_wind_direction(
|
||||||
date: Optional[str] = Query(
|
date: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
|
description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
|
||||||
),
|
),
|
||||||
pagination_token: Optional[str] = Query(
|
pagination_token: str | None = Query(
|
||||||
None, description="Pagination token for retrieving subsequent data pages"
|
None,
|
||||||
|
description="Pagination token for retrieving subsequent data pages",
|
||||||
),
|
),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Get wind direction readings across Singapore
|
Get wind direction readings across Singapore
|
||||||
|
|
||||||
- Has per-minute readings from NEA
|
- Has per-minute readings from NEA
|
||||||
- Unit of measure for readings is °
|
- Unit of measure for readings is °
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
if date:
|
if date:
|
||||||
params["date"] = date
|
params["date"] = date
|
||||||
@@ -253,24 +257,23 @@ async def get_wind_direction(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/lightning", response_model=LightningResponse)
|
@router.get("/lightning", response_model=LightningResponse)
|
||||||
|
@cache_response(_ttl=300, cache=weather_cache)
|
||||||
async def get_lightning(
|
async def get_lightning(
|
||||||
date: Optional[str] = Query(
|
date: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2025-01-16 or 2025-01-16T23:59:00",
|
description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2025-01-16 or 2025-01-16T23:59:00",
|
||||||
),
|
),
|
||||||
pagination_token: Optional[str] = Query(
|
pagination_token: str | None = Query(
|
||||||
None, description="Pagination token for retrieving subsequent data pages"
|
None,
|
||||||
|
description="Pagination token for retrieving subsequent data pages",
|
||||||
),
|
),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Retrieve the latest lightning observation
|
Retrieve the latest lightning observation
|
||||||
|
|
||||||
- Updated multiple times throughout the day
|
- Updated multiple times throughout the day
|
||||||
"""
|
"""
|
||||||
|
params = {"api": "lightning"}
|
||||||
params = {
|
|
||||||
"api": "lightning" # Required parameter for this endpoint
|
|
||||||
}
|
|
||||||
if date:
|
if date:
|
||||||
params["date"] = date
|
params["date"] = date
|
||||||
if pagination_token:
|
if pagination_token:
|
||||||
@@ -280,25 +283,24 @@ async def get_lightning(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/wbgt", response_model=WBGTResponse)
|
@router.get("/wbgt", response_model=WBGTResponse)
|
||||||
|
@cache_response(_ttl=300, cache=weather_cache)
|
||||||
async def get_wbgt(
|
async def get_wbgt(
|
||||||
date: Optional[str] = Query(
|
date: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2025-01-16 or 2025-01-16T23:59:00",
|
description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2025-01-16 or 2025-01-16T23:59:00",
|
||||||
),
|
),
|
||||||
pagination_token: Optional[str] = Query(
|
pagination_token: str | None = Query(
|
||||||
None, description="Pagination token for retrieving subsequent data pages"
|
None,
|
||||||
|
description="Pagination token for retrieving subsequent data pages",
|
||||||
),
|
),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Retrieve the latest WBGT (Wet Bulb Globe Temperature) data for accurate heat stress assessment
|
Retrieve the latest WBGT (Wet Bulb Globe Temperature) data for accurate heat stress assessment
|
||||||
|
|
||||||
- Updated multiple times throughout the day
|
- Updated multiple times throughout the day
|
||||||
- Unit of measure for readings is °C
|
- Unit of measure for readings is °C
|
||||||
"""
|
"""
|
||||||
|
params = {"api": "wbgt"}
|
||||||
params = {
|
|
||||||
"api": "wbgt" # Required parameter for this endpoint
|
|
||||||
}
|
|
||||||
if date:
|
if date:
|
||||||
params["date"] = date
|
params["date"] = date
|
||||||
if pagination_token:
|
if pagination_token:
|
||||||
@@ -308,52 +310,56 @@ async def get_wbgt(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/twenty-four-hour-forecast", response_model=TwentyFourHourForecastResponse)
|
@router.get("/twenty-four-hour-forecast", response_model=TwentyFourHourForecastResponse)
|
||||||
|
@cache_response(_ttl=3600, cache=weather_cache)
|
||||||
async def get_twenty_four_hour_forecast(
|
async def get_twenty_four_hour_forecast(
|
||||||
date: Optional[str] = Query(
|
date: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2024-07-16 or 2024-07-16T23:59:00",
|
description="SGT date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Example: 2024-07-16 or 2024-07-16T23:59:00",
|
||||||
),
|
),
|
||||||
pagination_token: Optional[str] = Query(
|
pagination_token: str | None = Query(
|
||||||
None, description="Pagination token for retrieving subsequent data pages"
|
None,
|
||||||
|
description="Pagination token for retrieving subsequent data pages",
|
||||||
),
|
),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Retrieve the latest 24 hour weather forecast
|
Retrieve the latest 24 hour weather forecast
|
||||||
|
|
||||||
- Updated multiple times throughout the day
|
- Updated multiple times throughout the day
|
||||||
- Provides forecasts for different areas of Singapore
|
- Provides forecasts for different areas of Singapore
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
if date:
|
if date:
|
||||||
params["date"] = date
|
params["date"] = date
|
||||||
if pagination_token:
|
if pagination_token:
|
||||||
params["paginationToken"] = pagination_token
|
params["paginationToken"] = pagination_token
|
||||||
|
|
||||||
return await make_api_request("twenty-four-hr-forecast", params, TwentyFourHourForecastResponse)
|
return await make_api_request(
|
||||||
|
"twenty-four-hr-forecast", params, TwentyFourHourForecastResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/four-day-outlook", response_model=FourDayForecastResponse)
|
@router.get("/four-day-outlook", response_model=FourDayForecastResponse)
|
||||||
|
@cache_response(_ttl=7200, cache=weather_cache)
|
||||||
async def get_four_day_outlook(
|
async def get_four_day_outlook(
|
||||||
date: Optional[str] = Query(
|
date: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
|
description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
|
||||||
),
|
),
|
||||||
pagination_token: Optional[str] = Query(
|
pagination_token: str | None = Query(
|
||||||
None, description="Pagination token for retrieving subsequent data pages"
|
None,
|
||||||
|
description="Pagination token for retrieving subsequent data pages",
|
||||||
),
|
),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Retrieve the latest 4 day weather forecast
|
Retrieve the latest 4 day weather forecast
|
||||||
|
|
||||||
- Updated twice a day from NEA
|
- Updated twice a day from NEA
|
||||||
- The forecast is for the next 4 days
|
- The forecast is for the next 4 days
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
if date:
|
if date:
|
||||||
params["date"] = date
|
params["date"] = date
|
||||||
if pagination_token:
|
if pagination_token:
|
||||||
params["paginationToken"] = pagination_token
|
params["paginationToken"] = pagination_token
|
||||||
|
|
||||||
return await make_api_request("four-day-outlook", params, FourDayForecastResponse)
|
return await make_api_request("four-day-outlook", params, FourDayForecastResponse)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class LRUCache:
|
||||||
|
def __init__(self, max_size: int = 128, ttl: int = 300):
|
||||||
|
self.max_size = max_size
|
||||||
|
self.ttl = ttl
|
||||||
|
self.cache: OrderedDict[str, tuple[Any, float]] = OrderedDict()
|
||||||
|
|
||||||
|
def get(self, key: str) -> Any | None:
|
||||||
|
if key not in self.cache:
|
||||||
|
return None
|
||||||
|
value, timestamp = self.cache[key]
|
||||||
|
if time.time() - timestamp > self.ttl:
|
||||||
|
del self.cache[key]
|
||||||
|
return None
|
||||||
|
self.cache.move_to_end(key)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any) -> None:
|
||||||
|
if key in self.cache:
|
||||||
|
self.cache.move_to_end(key)
|
||||||
|
elif len(self.cache) >= self.max_size:
|
||||||
|
self.cache.popitem(last=False)
|
||||||
|
self.cache[key] = (value, time.time())
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
weather_cache = LRUCache(max_size=64, ttl=300)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_response(*, _ttl: int = 300, cache: LRUCache | None = None):
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
target_cache = cache or weather_cache
|
||||||
|
cache_key = f"{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}"
|
||||||
|
cached = target_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
target_cache.set(cache_key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -4,7 +4,14 @@ from app import crud
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import User, UserCreate
|
from app.models import User, UserCreate
|
||||||
|
|
||||||
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
engine = create_engine(
|
||||||
|
str(settings.SQLALCHEMY_DATABASE_URI),
|
||||||
|
pool_size=20,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_timeout=30,
|
||||||
|
pool_recycle=3600,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# make sure all SQLModel models are imported (app.models) before initializing DB
|
# make sure all SQLModel models are imported (app.models) before initializing DB
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
@@ -7,6 +9,15 @@ from app.api.main import api_router
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
from app.api.routes.weather import _http_client
|
||||||
|
|
||||||
|
yield
|
||||||
|
if _http_client and not _http_client.is_closed:
|
||||||
|
await _http_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
def custom_generate_unique_id(route: APIRoute) -> str:
|
def custom_generate_unique_id(route: APIRoute) -> str:
|
||||||
return f"{route.tags[0]}-{route.name}"
|
return f"{route.tags[0]}-{route.name}"
|
||||||
|
|
||||||
@@ -18,6 +29,7 @@ app = FastAPI(
|
|||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||||
generate_unique_id_function=custom_generate_unique_id,
|
generate_unique_id_function=custom_generate_unique_id,
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set all CORS enabled origins
|
# Set all CORS enabled origins
|
||||||
|
|||||||
+111
-168
@@ -1,9 +1,10 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import EmailStr, BaseModel, Field, model_validator
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
from sqlmodel import Field as SQLModelField, Relationship, SQLModel
|
from sqlmodel import Field as SQLModelField
|
||||||
|
from sqlmodel import Relationship, SQLModel
|
||||||
|
|
||||||
|
|
||||||
# Shared properties
|
# Shared properties
|
||||||
@@ -119,7 +120,7 @@ class NewPassword(SQLModel):
|
|||||||
class LabelLocation(BaseModel):
|
class LabelLocation(BaseModel):
|
||||||
latitude: float
|
latitude: float
|
||||||
longitude: float
|
longitude: float
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore" # Ignore extra fields in API response
|
"extra": "ignore" # Ignore extra fields in API response
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,7 @@ class LabelLocation(BaseModel):
|
|||||||
class AreaMetadata(BaseModel):
|
class AreaMetadata(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
label_location: LabelLocation
|
label_location: LabelLocation
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore" # Ignore extra fields in API response
|
"extra": "ignore" # Ignore extra fields in API response
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ class ForecastPeriod(BaseModel):
|
|||||||
start: datetime
|
start: datetime
|
||||||
end: datetime
|
end: datetime
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore" # Ignore extra fields in API response
|
"extra": "ignore" # Ignore extra fields in API response
|
||||||
}
|
}
|
||||||
@@ -147,7 +148,7 @@ class ForecastPeriod(BaseModel):
|
|||||||
class Forecast(BaseModel):
|
class Forecast(BaseModel):
|
||||||
area: str
|
area: str
|
||||||
forecast: str
|
forecast: str
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore" # Ignore extra fields in API response
|
"extra": "ignore" # Ignore extra fields in API response
|
||||||
}
|
}
|
||||||
@@ -157,19 +158,19 @@ class WeatherItem(BaseModel):
|
|||||||
updated_timestamp: datetime = Field(alias="update_timestamp")
|
updated_timestamp: datetime = Field(alias="update_timestamp")
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
valid_period: ForecastPeriod
|
valid_period: ForecastPeriod
|
||||||
forecasts: List[Forecast]
|
forecasts: list[Forecast]
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore", # Ignore extra fields in API response
|
"extra": "ignore", # Ignore extra fields in API response
|
||||||
"populate_by_name": True # Allow populating by field name or alias
|
"populate_by_name": True, # Allow populating by field name or alias
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class WeatherData(BaseModel):
|
class WeatherData(BaseModel):
|
||||||
area_metadata: List[AreaMetadata]
|
area_metadata: list[AreaMetadata]
|
||||||
items: List[WeatherItem]
|
items: list[WeatherItem]
|
||||||
pagination_token: Optional[str] = None
|
pagination_token: str | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore" # Ignore extra fields in API response
|
"extra": "ignore" # Ignore extra fields in API response
|
||||||
}
|
}
|
||||||
@@ -177,21 +178,21 @@ class WeatherData(BaseModel):
|
|||||||
|
|
||||||
class WeatherResponse(BaseModel):
|
class WeatherResponse(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
error_msg: Optional[str] = Field(None, alias="errorMsg")
|
error_msg: str | None = Field(None, alias="errorMsg")
|
||||||
data: Optional[Dict[str, Any]] = None
|
data: dict[str, Any] | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore", # Ignore extra fields in API response
|
"extra": "ignore", # Ignore extra fields in API response
|
||||||
"populate_by_name": True # Allow populating by field name or alias
|
"populate_by_name": True, # Allow populating by field name or alias
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class WeatherApiError(BaseModel):
|
class WeatherApiError(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
name: str
|
name: str
|
||||||
data: Optional[Dict[str, Any]] = None
|
data: dict[str, Any] | None = None
|
||||||
error_msg: str = Field(alias="errorMsg")
|
error_msg: str = Field(alias="errorMsg")
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore" # Ignore extra fields in API response
|
"extra": "ignore" # Ignore extra fields in API response
|
||||||
}
|
}
|
||||||
@@ -200,13 +201,13 @@ class WeatherApiError(BaseModel):
|
|||||||
# Models for air temperature readings
|
# Models for air temperature readings
|
||||||
class Station(BaseModel):
|
class Station(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
device_id: Optional[str] = Field(None, alias="deviceId") # API returns deviceId
|
device_id: str | None = Field(None, alias="deviceId") # API returns deviceId
|
||||||
name: str
|
name: str
|
||||||
location: Dict[str, float] # Contains latitude and longitude
|
location: dict[str, float] # Contains latitude and longitude
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore", # Ignore extra fields in API response
|
"extra": "ignore", # Ignore extra fields in API response
|
||||||
"populate_by_name": True # Allow both snake_case and camelCase
|
"populate_by_name": True, # Allow both snake_case and camelCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -214,70 +215,52 @@ class Station(BaseModel):
|
|||||||
class ReadingDataPoint(BaseModel):
|
class ReadingDataPoint(BaseModel):
|
||||||
station_id: str = Field(alias="stationId")
|
station_id: str = Field(alias="stationId")
|
||||||
value: float
|
value: float
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Reading(BaseModel):
|
class Reading(BaseModel):
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
data: List[ReadingDataPoint]
|
data: list[ReadingDataPoint]
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AirTemperatureData(BaseModel):
|
class AirTemperatureData(BaseModel):
|
||||||
stations: List[Station]
|
stations: list[Station]
|
||||||
readings: List[Reading]
|
readings: list[Reading]
|
||||||
reading_type: str = Field(alias="readingType")
|
reading_type: str = Field(alias="readingType")
|
||||||
reading_unit: str = Field(alias="readingUnit")
|
reading_unit: str = Field(alias="readingUnit")
|
||||||
pagination_token: Optional[str] = Field(None, alias="paginationToken")
|
pagination_token: str | None = Field(None, alias="paginationToken")
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AirTemperatureResponse(BaseModel):
|
class AirTemperatureResponse(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
error_msg: Optional[str] = Field(None, alias="errorMsg")
|
error_msg: str | None = Field(None, alias="errorMsg")
|
||||||
data: Optional[AirTemperatureData] = None
|
data: AirTemperatureData | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Models for wind direction readings
|
# Models for wind direction readings
|
||||||
class WindDirectionData(BaseModel):
|
class WindDirectionData(BaseModel):
|
||||||
stations: List[Station]
|
stations: list[Station]
|
||||||
readings: List[Reading]
|
readings: list[Reading]
|
||||||
reading_type: str = Field(alias="readingType")
|
reading_type: str = Field(alias="readingType")
|
||||||
reading_unit: str = Field(alias="readingUnit")
|
reading_unit: str = Field(alias="readingUnit")
|
||||||
pagination_token: Optional[str] = Field(None, alias="paginationToken")
|
pagination_token: str | None = Field(None, alias="paginationToken")
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WindDirectionResponse(BaseModel):
|
class WindDirectionResponse(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
error_msg: Optional[str] = Field(None, alias="errorMsg")
|
error_msg: str | None = Field(None, alias="errorMsg")
|
||||||
data: Optional[WindDirectionData] = None
|
data: WindDirectionData | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Models for lightning observation
|
# Models for lightning observation
|
||||||
@@ -285,73 +268,55 @@ class LightningItem(BaseModel):
|
|||||||
# Generic item model for lightning data
|
# Generic item model for lightning data
|
||||||
# The actual structure can be expanded based on the real API response
|
# The actual structure can be expanded based on the real API response
|
||||||
pass
|
pass
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore"}
|
||||||
"extra": "ignore"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LightningRecord(BaseModel):
|
class LightningRecord(BaseModel):
|
||||||
datetime: str
|
datetime: str
|
||||||
item: Dict[str, Any] # Flexible structure to accommodate different data formats
|
item: dict[str, Any] # Flexible structure to accommodate different data formats
|
||||||
updated_timestamp: Optional[datetime] = Field(None)
|
updated_timestamp: datetime | None = Field(None)
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore"}
|
||||||
"extra": "ignore"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LightningData(BaseModel):
|
class LightningData(BaseModel):
|
||||||
records: List[LightningRecord]
|
records: list[LightningRecord]
|
||||||
pagination_token: Optional[str] = Field(None, alias="paginationToken")
|
pagination_token: str | None = Field(None, alias="paginationToken")
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LightningResponse(BaseModel):
|
class LightningResponse(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
error_msg: Optional[str] = Field(None, alias="errorMsg")
|
error_msg: str | None = Field(None, alias="errorMsg")
|
||||||
data: Optional[LightningData] = None
|
data: LightningData | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Models for WBGT (Wet Bulb Globe Temperature) observations
|
# Models for WBGT (Wet Bulb Globe Temperature) observations
|
||||||
class WBGTRecord(BaseModel):
|
class WBGTRecord(BaseModel):
|
||||||
datetime: str
|
datetime: str
|
||||||
item: Dict[str, Any] # Flexible structure to accommodate different data formats
|
item: dict[str, Any] # Flexible structure to accommodate different data formats
|
||||||
updated_timestamp: Optional[datetime] = Field(None)
|
updated_timestamp: datetime | None = Field(None)
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore"}
|
||||||
"extra": "ignore"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WBGTData(BaseModel):
|
class WBGTData(BaseModel):
|
||||||
records: List[WBGTRecord]
|
records: list[WBGTRecord]
|
||||||
pagination_token: Optional[str] = Field(None, alias="paginationToken")
|
pagination_token: str | None = Field(None, alias="paginationToken")
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WBGTResponse(BaseModel):
|
class WBGTResponse(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
error_msg: Optional[str] = Field(None, alias="errorMsg")
|
error_msg: str | None = Field(None, alias="errorMsg")
|
||||||
data: Optional[WBGTData] = None
|
data: WBGTData | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Models for 24-hour weather forecast
|
# Models for 24-hour weather forecast
|
||||||
@@ -359,48 +324,38 @@ class ForecastPeriodGeneral(BaseModel):
|
|||||||
start: datetime
|
start: datetime
|
||||||
end: datetime
|
end: datetime
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore"}
|
||||||
"extra": "ignore"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TemperatureRange(BaseModel):
|
class TemperatureRange(BaseModel):
|
||||||
low: int
|
low: int
|
||||||
high: int
|
high: int
|
||||||
unit: str
|
unit: str
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore"}
|
||||||
"extra": "ignore"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class HumidityRange(BaseModel):
|
class HumidityRange(BaseModel):
|
||||||
low: int
|
low: int
|
||||||
high: int
|
high: int
|
||||||
unit: str
|
unit: str
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore"}
|
||||||
"extra": "ignore"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ForecastInfo(BaseModel):
|
class ForecastInfo(BaseModel):
|
||||||
code: Optional[str] = None
|
code: str | None = None
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore"}
|
||||||
"extra": "ignore"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WindInfo(BaseModel):
|
class WindInfo(BaseModel):
|
||||||
speed: Dict[str, Any]
|
speed: dict[str, Any]
|
||||||
direction: str
|
direction: str
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore"}
|
||||||
"extra": "ignore"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class GeneralForecast(BaseModel):
|
class GeneralForecast(BaseModel):
|
||||||
@@ -409,11 +364,8 @@ class GeneralForecast(BaseModel):
|
|||||||
relative_humidity: HumidityRange = Field(alias="relativeHumidity")
|
relative_humidity: HumidityRange = Field(alias="relativeHumidity")
|
||||||
forecast: ForecastInfo
|
forecast: ForecastInfo
|
||||||
wind: WindInfo
|
wind: WindInfo
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ForecastItem(BaseModel):
|
class ForecastItem(BaseModel):
|
||||||
@@ -421,34 +373,25 @@ class ForecastItem(BaseModel):
|
|||||||
updated_timestamp: datetime = Field(alias="updatedTimestamp")
|
updated_timestamp: datetime = Field(alias="updatedTimestamp")
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
general: GeneralForecast
|
general: GeneralForecast
|
||||||
periods: Optional[List[Dict[str, Any]]] = None
|
periods: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TwentyFourHourForecastData(BaseModel):
|
class TwentyFourHourForecastData(BaseModel):
|
||||||
area_metadata: Optional[List[AreaMetadata]] = Field(default_factory=list)
|
area_metadata: list[AreaMetadata] | None = Field(default_factory=list)
|
||||||
records: List[ForecastItem]
|
records: list[ForecastItem]
|
||||||
pagination_token: Optional[str] = Field(None, alias="paginationToken")
|
pagination_token: str | None = Field(None, alias="paginationToken")
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TwentyFourHourForecastResponse(BaseModel):
|
class TwentyFourHourForecastResponse(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
error_msg: Optional[str] = Field(None, alias="errorMsg")
|
error_msg: str | None = Field(None, alias="errorMsg")
|
||||||
data: Optional[TwentyFourHourForecastData] = None
|
data: TwentyFourHourForecastData | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {"extra": "ignore", "populate_by_name": True}
|
||||||
"extra": "ignore",
|
|
||||||
"populate_by_name": True
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Models for 4-day weather forecast
|
# Models for 4-day weather forecast
|
||||||
@@ -456,30 +399,30 @@ class FourDayForecastItem(BaseModel):
|
|||||||
date: str
|
date: str
|
||||||
updated_timestamp: datetime = Field(alias="updatedTimestamp")
|
updated_timestamp: datetime = Field(alias="updatedTimestamp")
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
forecasts: List[Dict[str, Any]] # Flexible structure for the forecasts
|
forecasts: list[dict[str, Any]] # Flexible structure for the forecasts
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore",
|
"extra": "ignore",
|
||||||
"populate_by_name": True # Allow both snake_case and camelCase
|
"populate_by_name": True, # Allow both snake_case and camelCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class FourDayForecastData(BaseModel):
|
class FourDayForecastData(BaseModel):
|
||||||
records: List[FourDayForecastItem]
|
records: list[FourDayForecastItem]
|
||||||
pagination_token: Optional[str] = Field(None, alias="paginationToken")
|
pagination_token: str | None = Field(None, alias="paginationToken")
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore",
|
"extra": "ignore",
|
||||||
"populate_by_name": True # Allow both snake_case and camelCase
|
"populate_by_name": True, # Allow both snake_case and camelCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class FourDayForecastResponse(BaseModel):
|
class FourDayForecastResponse(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
error_msg: Optional[str] = Field(None, alias="errorMsg")
|
error_msg: str | None = Field(None, alias="errorMsg")
|
||||||
data: Optional[FourDayForecastData] = None
|
data: FourDayForecastData | None = None
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"extra": "ignore",
|
"extra": "ignore",
|
||||||
"populate_by_name": True # Allow both snake_case and camelCase
|
"populate_by_name": True, # Allow both snake_case and camelCase
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ def test_init_successful_connection() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
connection_successful = False
|
connection_successful = False
|
||||||
|
|
||||||
assert (
|
assert connection_successful, (
|
||||||
connection_successful
|
"The database connection should be successful and not raise an exception."
|
||||||
), "The database connection should be successful and not raise an exception."
|
)
|
||||||
|
|
||||||
assert session_mock.exec.called_once_with(
|
assert session_mock.exec.called_once_with(select(1)), (
|
||||||
select(1)
|
"The session should execute a select statement once."
|
||||||
), "The session should execute a select statement once."
|
)
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ def test_init_successful_connection() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
connection_successful = False
|
connection_successful = False
|
||||||
|
|
||||||
assert (
|
assert connection_successful, (
|
||||||
connection_successful
|
"The database connection should be successful and not raise an exception."
|
||||||
), "The database connection should be successful and not raise an exception."
|
)
|
||||||
|
|
||||||
assert session_mock.exec.called_once_with(
|
assert session_mock.exec.called_once_with(select(1)), (
|
||||||
select(1)
|
"The session should execute a select statement once."
|
||||||
), "The session should execute a select statement once."
|
)
|
||||||
|
|||||||
+3
-2
@@ -7,9 +7,10 @@ ARG VITE_API_URL
|
|||||||
ARG NODE_ENV=production
|
ARG NODE_ENV=production
|
||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
|
|
||||||
COPY package*.json /app/
|
COPY package.json package-lock.json /app/
|
||||||
|
|
||||||
RUN NODE_ENV=development npm ci
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --cache=/root/.npm
|
||||||
|
|
||||||
ENV NODE_ENV=${NODE_ENV}
|
ENV NODE_ENV=${NODE_ENV}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ FROM node:24
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json /app/
|
COPY package.json package-lock.json /app/
|
||||||
|
|
||||||
RUN npm install
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --cache=/root/.npm
|
||||||
|
|
||||||
RUN npx -y playwright install --with-deps
|
RUN npx -y playwright install --with-deps
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user