v1
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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),
|
||||
)
|
||||
Reference in New Issue
Block a user