mirror of
https://github.com/furyhawk/home_stack.git
synced 2026-07-21 10:16:47 +00:00
⬆ Add weather API integration and update models for two-hour forecast
This commit is contained in:
+4
-3
@@ -20,18 +20,19 @@ ENV UV_COMPILE_BYTECODE=1
|
||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Copy dependency files first
|
||||
COPY ./pyproject.toml ./uv.lock /app/
|
||||
|
||||
# Install dependencies
|
||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
COPY ./scripts /app/scripts
|
||||
|
||||
COPY ./pyproject.toml ./uv.lock ./alembic.ini /app/
|
||||
COPY ./alembic.ini /app/
|
||||
|
||||
COPY ./app /app/app
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import items, login, private, users, utils
|
||||
from app.api.routes import items, login, private, users, utils, weather
|
||||
from app.core.config import settings
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -8,6 +8,7 @@ api_router.include_router(login.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(utils.router)
|
||||
api_router.include_router(items.router)
|
||||
api_router.include_router(weather.router)
|
||||
|
||||
|
||||
if settings.ENVIRONMENT == "local":
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import ValidationError
|
||||
|
||||
# Import directly from models file
|
||||
from app.models import WeatherApiError, WeatherResponse
|
||||
|
||||
router = APIRouter(prefix="/weather", tags=["weather"])
|
||||
|
||||
WEATHER_API_BASE_URL = "https://api-open.data.gov.sg/v2/real-time/api"
|
||||
|
||||
|
||||
@router.get("/two-hour-forecast", response_model=WeatherResponse)
|
||||
async def get_two_hour_forecast(
|
||||
date: Optional[str] = Query(
|
||||
None,
|
||||
description="SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
|
||||
),
|
||||
pagination_token: Optional[str] = Query(
|
||||
None, description="Pagination token for retrieving subsequent data pages"
|
||||
),
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve the latest two hour weather forecast from data.gov.sg API.
|
||||
|
||||
- Updated half-hourly from NEA
|
||||
- Forecasts are given for multiple areas in Singapore
|
||||
"""
|
||||
|
||||
# Build the URL and parameters
|
||||
url = f"{WEATHER_API_BASE_URL}/two-hr-forecast"
|
||||
params = {}
|
||||
if date:
|
||||
params["date"] = date
|
||||
if pagination_token:
|
||||
params["paginationToken"] = pagination_token
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, params=params)
|
||||
|
||||
# Handle error responses
|
||||
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:
|
||||
data = response.json()
|
||||
# Debug: Print the keys received from the API response
|
||||
print(f"API response keys: {data.keys()}")
|
||||
if 'items' in data.get('data', {}):
|
||||
print(f"First item keys: {data['data']['items'][0].keys()}")
|
||||
|
||||
# Try parsing with the model
|
||||
return WeatherResponse(**data)
|
||||
except ValidationError as e:
|
||||
# If there's a validation error, try to provide more detailed error information
|
||||
print(f"Validation error parsing weather data: {str(e)}")
|
||||
|
||||
# If the error is specifically about missing update_timestamp vs updated_timestamp,
|
||||
# try to manually fix the response data
|
||||
if 'update_timestamp' in str(e) or 'updated_timestamp' in str(e):
|
||||
# Try to manually fix the data structure
|
||||
if 'data' in data and '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']
|
||||
|
||||
# Try parsing again with the fixed data
|
||||
try:
|
||||
return WeatherResponse(**data)
|
||||
except ValidationError:
|
||||
# If it still fails, fall back to the original error
|
||||
pass
|
||||
|
||||
# If we couldn't fix it, raise the original error
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error parsing weather data: {str(e)}"
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Error communicating with weather API: {str(e)}"
|
||||
)
|
||||
+97
-21
@@ -1,47 +1,49 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import EmailStr
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
from pydantic import EmailStr, BaseModel, Field
|
||||
from sqlmodel import Field as SQLModelField, Relationship, SQLModel
|
||||
|
||||
|
||||
# Shared properties
|
||||
class UserBase(SQLModel):
|
||||
email: EmailStr = Field(unique=True, index=True, max_length=255)
|
||||
email: EmailStr = SQLModelField(unique=True, index=True, max_length=255)
|
||||
is_active: bool = True
|
||||
is_superuser: bool = False
|
||||
full_name: str | None = Field(default=None, max_length=255)
|
||||
full_name: str | None = SQLModelField(default=None, max_length=255)
|
||||
|
||||
|
||||
# Properties to receive via API on creation
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(min_length=8, max_length=40)
|
||||
password: str = SQLModelField(min_length=8, max_length=40)
|
||||
|
||||
|
||||
class UserRegister(SQLModel):
|
||||
email: EmailStr = Field(max_length=255)
|
||||
password: str = Field(min_length=8, max_length=40)
|
||||
full_name: str | None = Field(default=None, max_length=255)
|
||||
email: EmailStr = SQLModelField(max_length=255)
|
||||
password: str = SQLModelField(min_length=8, max_length=40)
|
||||
full_name: str | None = SQLModelField(default=None, max_length=255)
|
||||
|
||||
|
||||
# Properties to receive via API on update, all are optional
|
||||
class UserUpdate(UserBase):
|
||||
email: EmailStr | None = Field(default=None, max_length=255) # type: ignore
|
||||
password: str | None = Field(default=None, min_length=8, max_length=40)
|
||||
email: EmailStr | None = SQLModelField(default=None, max_length=255) # type: ignore
|
||||
password: str | None = SQLModelField(default=None, min_length=8, max_length=40)
|
||||
|
||||
|
||||
class UserUpdateMe(SQLModel):
|
||||
full_name: str | None = Field(default=None, max_length=255)
|
||||
email: EmailStr | None = Field(default=None, max_length=255)
|
||||
full_name: str | None = SQLModelField(default=None, max_length=255)
|
||||
email: EmailStr | None = SQLModelField(default=None, max_length=255)
|
||||
|
||||
|
||||
class UpdatePassword(SQLModel):
|
||||
current_password: str = Field(min_length=8, max_length=40)
|
||||
new_password: str = Field(min_length=8, max_length=40)
|
||||
current_password: str = SQLModelField(min_length=8, max_length=40)
|
||||
new_password: str = SQLModelField(min_length=8, max_length=40)
|
||||
|
||||
|
||||
# Database model, database table inferred from class name
|
||||
class User(UserBase, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
id: uuid.UUID = SQLModelField(default_factory=uuid.uuid4, primary_key=True)
|
||||
hashed_password: str
|
||||
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||
|
||||
@@ -58,8 +60,8 @@ class UsersPublic(SQLModel):
|
||||
|
||||
# Shared properties
|
||||
class ItemBase(SQLModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
title: str = SQLModelField(min_length=1, max_length=255)
|
||||
description: str | None = SQLModelField(default=None, max_length=255)
|
||||
|
||||
|
||||
# Properties to receive on item creation
|
||||
@@ -69,13 +71,13 @@ class ItemCreate(ItemBase):
|
||||
|
||||
# Properties to receive on item update
|
||||
class ItemUpdate(ItemBase):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
|
||||
title: str | None = SQLModelField(default=None, min_length=1, max_length=255) # type: ignore
|
||||
|
||||
|
||||
# Database model, database table inferred from class name
|
||||
class Item(ItemBase, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
owner_id: uuid.UUID = Field(
|
||||
id: uuid.UUID = SQLModelField(default_factory=uuid.uuid4, primary_key=True)
|
||||
owner_id: uuid.UUID = SQLModelField(
|
||||
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
||||
)
|
||||
owner: User | None = Relationship(back_populates="items")
|
||||
@@ -110,4 +112,78 @@ class TokenPayload(SQLModel):
|
||||
|
||||
class NewPassword(SQLModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=8, max_length=40)
|
||||
new_password: str = SQLModelField(min_length=8, max_length=40)
|
||||
|
||||
|
||||
# Weather models
|
||||
class LabelLocation(BaseModel):
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields in API response
|
||||
|
||||
|
||||
class AreaMetadata(BaseModel):
|
||||
name: str
|
||||
label_location: LabelLocation
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields in API response
|
||||
|
||||
|
||||
class ForecastPeriod(BaseModel):
|
||||
start: datetime
|
||||
end: datetime
|
||||
text: str
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields in API response
|
||||
|
||||
|
||||
class Forecast(BaseModel):
|
||||
area: str
|
||||
forecast: str
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields in API response
|
||||
|
||||
|
||||
class WeatherItem(BaseModel):
|
||||
updated_timestamp: datetime = Field(alias="update_timestamp")
|
||||
timestamp: datetime
|
||||
valid_period: ForecastPeriod
|
||||
forecasts: List[Forecast]
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields in API response
|
||||
populate_by_name = True # Allow populating by field name or alias
|
||||
|
||||
|
||||
class WeatherData(BaseModel):
|
||||
area_metadata: List[AreaMetadata]
|
||||
items: List[WeatherItem]
|
||||
pagination_token: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields in API response
|
||||
|
||||
|
||||
class WeatherResponse(BaseModel):
|
||||
code: int
|
||||
error_msg: Optional[str] = Field(None, alias="errorMsg")
|
||||
data: Optional[WeatherData] = None
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields in API response
|
||||
populate_by_name = True # Allow populating by field name or alias
|
||||
|
||||
|
||||
class WeatherApiError(BaseModel):
|
||||
code: int
|
||||
name: str
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error_msg: str = Field(alias="errorMsg")
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields in API response
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Real-time API weather services",
|
||||
"description": "Real-time API documentation of weather services",
|
||||
"contact": {
|
||||
"email": "feedback@data.gov.sg"
|
||||
},
|
||||
"version": "1.0.11"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://api-open.data.gov.sg/v2/real-time/api"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/two-hr-forecast": {
|
||||
"get": {
|
||||
"summary": "Retrieve the latest two hour weather forecast",
|
||||
"description": "**[https://api-open.data.gov.sg/v2/real-time/api/two-hr-forecast](https://api-open.data.gov.sg/v2/real-time/api/two-hr-forecast)**\n\n<br/>\n\n- Updated half-hourly from NEA\n- Forecasts are given for multiple areas in Singapore\n\n<br/>\n\n- Filter for specific date or date-time by providing `date` in query parameter.\n - use YYYY-MM-DD format to retrieve all of the readings for that day\n - use YYYY-MM-DDTHH:mm:ss to retrieve the latest readings at that moment in time\n - example: `?date=2024-07-16` or `?date=2024-07-16T23:59:00`\n\n<br/>\n\n- Possible values for forecast include:\n - Fair\n - Fair (Day)\n - Fair (Night)\n - Fair and Warm\n - Partly Cloudy\n - Partly Cloudy (Day)\n - Partly Cloudy (Night)\n - Cloudy\n - Hazy\n - Slightly Hazy\n - Windy\n - Mist\n - Fog\n - Light Rain\n - Moderate Rain\n - Heavy Rain\n - Passing Showers\n - Light Showers\n - Showers\n - Heavy Showers\n - Thundery Showers\n - Heavy Thundery Showers\n - Heavy Thundery Showers with Gusty Winds\n\n<br/>\n\n- Area metadata\n - The `area_metadata` field in the response provides longitude/latitude information for the areas. You can use that to place the forecasts on a map.\n",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "date",
|
||||
"description": "SGT date for which to retrieve data (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "paginationToken",
|
||||
"description": "Pagination token for retrieving subsequent data pages",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "2 Hour Weather Forecast",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"description": "Response status code (always 0 for success)"
|
||||
},
|
||||
"errorMsg": {
|
||||
"type": "string",
|
||||
"description": "Error message (empty string for success)",
|
||||
"example": null
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"area_metadata": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the area",
|
||||
"example": "Ang Mo Kio"
|
||||
},
|
||||
"label_location": {
|
||||
"type": "object",
|
||||
"description": "Provides longitude and latitude for placing readings on a map",
|
||||
"properties": {
|
||||
"latitude": {
|
||||
"type": "number",
|
||||
"example": 1.375
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number",
|
||||
"example": 103.839
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"updated_timestamp": {
|
||||
"type": "string",
|
||||
"description": "Time of acquisition of data from NEA",
|
||||
"example": "2024-07-17T05:05:54.000Z"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"description": "Time forecast was issued by NEA",
|
||||
"example": "2024-07-17T04:59:00.000Z"
|
||||
},
|
||||
"valid_period": {
|
||||
"type": "object",
|
||||
"description": "Period of time the forecast is valid for",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "string",
|
||||
"example": "2024-07-16T16:30:00.000Z"
|
||||
},
|
||||
"end": {
|
||||
"type": "string",
|
||||
"example": "2024-07-16T18:30:00.000Z"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"example": "12.30 am to 2.30 am"
|
||||
}
|
||||
}
|
||||
},
|
||||
"forecasts": {
|
||||
"type": "array",
|
||||
"description": "Forecasts for various areas in Singapore",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"area": {
|
||||
"type": "string",
|
||||
"example": "Ang Mo Kio"
|
||||
},
|
||||
"forecast": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Fair",
|
||||
"Fair (Day)",
|
||||
"Fair (Night)",
|
||||
"Fair and Warm",
|
||||
"Partly Cloudy",
|
||||
"Partly Cloudy (Day)",
|
||||
"Partly Cloudy (Night)",
|
||||
"Cloudy",
|
||||
"Hazy",
|
||||
"Slightly Hazy",
|
||||
"Windy",
|
||||
"Mist",
|
||||
"Fog",
|
||||
"Light Rain",
|
||||
"Moderate Rain",
|
||||
"Heavy Rain",
|
||||
"Passing Showers",
|
||||
"Light Showers",
|
||||
"Showers",
|
||||
"Heavy Showers",
|
||||
"Thundery Showers",
|
||||
"Heavy Thundery Showers",
|
||||
"Heavy Thundery Showers with Gusty Winds"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paginationToken": {
|
||||
"type": "string",
|
||||
"description": "Token to retrieve next page if exists",
|
||||
"example": "b2Zmc2V0PTEwMA== (you will see this token only if next page exists)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid HTTP request body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "number",
|
||||
"example": 4
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "ERROR_PARAMS"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"example": null
|
||||
},
|
||||
"errorMsg": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Invalid date format. Date format must be YYYY-MM-DD (2024-06-01) or YYYY-MM-DDTHH:mm:ss (2024-06-01T08:30:00).",
|
||||
"Invalid pagination token."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Weather data not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "number",
|
||||
"example": 17
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "REAL_TIME_API_DATA_NOT_FOUND"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"example": null
|
||||
},
|
||||
"errorMsg": {
|
||||
"type": "string",
|
||||
"example": "Data not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ down:
|
||||
@echo "Containers stopped. Use 'make up' to start them again."
|
||||
|
||||
build:
|
||||
podman compose --podman-build-args="--network=host" build
|
||||
podman-compose build --build-arg network=host
|
||||
@echo "Containers built. Use 'make up' to start them."
|
||||
|
||||
restart: down build up
|
||||
|
||||
Reference in New Issue
Block a user