Files
gw/backend/app/routers/settings.py
T

203 lines
7.1 KiB
Python
Raw Normal View History

2026-04-18 07:23:55 +12:00
from datetime import UTC, datetime, timedelta
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import get_current_user
from app.database import get_db
from app.models.user import User
from app.schemas.settings import (
FeatureSettingsResponse,
FeatureSettingsUpdate,
PlannerWeatherResponse,
ServicePricingSettingsResponse,
ServicePricingSettingsUpdate,
SiteSettingsResponse,
SiteSettingsUpdate,
)
from app.services import settings as settings_service
from app.services.settings import FeatureSettingsSchemaOutdatedError, ServicePricingSchemaOutdatedError
router = APIRouter(prefix="/settings", tags=["Settings"])
PLANNER_WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
PLANNER_WEATHER_TTL = timedelta(hours=1)
PLANNER_WEATHER_CACHE = {
"fetched_at": datetime.min.replace(tzinfo=UTC),
"weather": {},
}
async def _load_planner_weather_snapshot() -> tuple[datetime, dict[str, dict[str, int]]]:
fetched_at = PLANNER_WEATHER_CACHE["fetched_at"]
cached_weather = PLANNER_WEATHER_CACHE["weather"]
now = datetime.now(UTC)
if cached_weather and now - fetched_at < PLANNER_WEATHER_TTL:
return fetched_at, cached_weather
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
PLANNER_WEATHER_URL,
params={
"latitude": -36.85,
"longitude": 174.77,
"daily": "weathercode,temperature_2m_max,temperature_2m_min",
"timezone": "Pacific/Auckland",
"forecast_days": 16,
"past_days": 14,
},
)
response.raise_for_status()
payload = response.json()
except httpx.HTTPError:
if cached_weather:
return fetched_at, cached_weather
raise
next_weather: dict[str, dict[str, int]] = {}
daily = payload.get("daily") or {}
dates = daily.get("time") or []
codes = daily.get("weathercode") or []
highs = daily.get("temperature_2m_max") or []
lows = daily.get("temperature_2m_min") or []
for index, date_key in enumerate(dates):
if index >= len(codes) or index >= len(highs) or index >= len(lows):
continue
next_weather[date_key] = {
"code": int(codes[index]),
"max": round(highs[index]),
"min": round(lows[index]),
}
fetched_at = now
PLANNER_WEATHER_CACHE["fetched_at"] = fetched_at
PLANNER_WEATHER_CACHE["weather"] = next_weather
return fetched_at, next_weather
def _filter_planner_weather(
weather: dict[str, dict[str, int]],
start_date: str | None,
end_date: str | None,
) -> dict[str, dict[str, int]]:
if not start_date and not end_date:
return weather
filtered: dict[str, dict[str, int]] = {}
for key, value in weather.items():
if start_date and key < start_date:
continue
if end_date and key > end_date:
continue
filtered[key] = value
return filtered
@router.get("", response_model=SiteSettingsResponse)
async def get_settings(db: AsyncSession = Depends(get_db)):
"""Get site settings singleton."""
row = await settings_service.get_settings(db)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Site settings have not been configured yet. Run seed.py to initialise.",
)
return SiteSettingsResponse.model_validate(row)
@router.put("", response_model=SiteSettingsResponse)
async def update_settings(
data: SiteSettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create or update site settings singleton (auth required)."""
row = await settings_service.upsert_settings(db, data)
return SiteSettingsResponse.model_validate(row)
@router.get("/features", response_model=FeatureSettingsResponse)
async def get_feature_settings(db: AsyncSession = Depends(get_db)):
snapshot = await settings_service.get_feature_settings_snapshot(db)
return FeatureSettingsResponse(
bookings_enabled=snapshot.bookings_enabled,
walks_enabled=snapshot.walks_enabled,
messages_enabled=snapshot.messages_enabled,
two_factor_enabled=snapshot.two_factor_enabled,
audit_history_enabled=snapshot.audit_history_enabled,
experiments_enabled=snapshot.experiments_enabled,
)
@router.put("/features", response_model=FeatureSettingsResponse)
async def update_feature_settings(
data: FeatureSettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
del current_user
try:
snapshot = await settings_service.update_feature_settings_snapshot(db, data)
except FeatureSettingsSchemaOutdatedError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return FeatureSettingsResponse(
bookings_enabled=snapshot.bookings_enabled,
walks_enabled=snapshot.walks_enabled,
messages_enabled=snapshot.messages_enabled,
two_factor_enabled=snapshot.two_factor_enabled,
audit_history_enabled=snapshot.audit_history_enabled,
experiments_enabled=snapshot.experiments_enabled,
)
@router.get("/pricing", response_model=ServicePricingSettingsResponse)
async def get_service_pricing(db: AsyncSession = Depends(get_db)):
snapshot = await settings_service.get_service_pricing_snapshot(db)
return ServicePricingSettingsResponse(service_pricing=snapshot)
@router.put("/pricing", response_model=ServicePricingSettingsResponse)
async def update_service_pricing(
data: ServicePricingSettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
del current_user
try:
snapshot = await settings_service.update_service_pricing_snapshot(
db,
service_pricing=data.service_pricing,
)
except ServicePricingSchemaOutdatedError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return ServicePricingSettingsResponse(service_pricing=snapshot)
@router.get("/planner-weather", response_model=PlannerWeatherResponse)
async def get_planner_weather(
start_date: str | None = Query(default=None),
end_date: str | None = Query(default=None),
current_user: User = Depends(get_current_user),
):
del current_user
for value, label in ((start_date, "start_date"), (end_date, "end_date")):
if not value:
continue
try:
datetime.strptime(value, "%Y-%m-%d")
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{label} must use YYYY-MM-DD format.",
) from exc
fetched_at, weather = await _load_planner_weather_snapshot()
return PlannerWeatherResponse(
fetched_at=fetched_at,
weather=_filter_planner_weather(weather, start_date, end_date),
)