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), )