203 lines
7.1 KiB
Python
203 lines
7.1 KiB
Python
|
|
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),
|
||
|
|
)
|