""" Service layer for SiteSettings singleton. Uses get-or-create pattern; only one row should ever exist. """ from dataclasses import dataclass from typing import Optional from sqlalchemy import inspect as sa_inspect, select from sqlalchemy.orm import load_only from sqlalchemy.ext.asyncio import AsyncSession from app.models.settings import SiteSettings from app.schemas.settings import FeatureSettingsUpdate, SiteSettingsUpdate from app.services.pricing import default_service_pricing, normalize_service_pricing @dataclass(frozen=True) class FeatureSettingsSnapshot: bookings_enabled: bool = True walks_enabled: bool = True messages_enabled: bool = True two_factor_enabled: bool = True audit_history_enabled: bool = True experiments_enabled: bool = True class FeatureSettingsSchemaOutdatedError(RuntimeError): """Raised when feature settings are requested against an older schema.""" class ServicePricingSchemaOutdatedError(RuntimeError): """Raised when service pricing is requested against an older schema.""" async def _get_site_settings_column_names(db: AsyncSession) -> set[str]: connection = await db.connection() def _load_columns(sync_connection): inspector = sa_inspect(sync_connection) return {column["name"] for column in inspector.get_columns("site_settings")} return await connection.run_sync(_load_columns) async def get_settings(db: AsyncSession, *, existing_columns: set[str] | None = None) -> Optional[SiteSettings]: column_names = existing_columns or await _get_site_settings_column_names(db) loadable_fields = [ getattr(SiteSettings, column_name) for column_name in column_names if hasattr(SiteSettings, column_name) ] statement = select(SiteSettings).limit(1) if loadable_fields: statement = statement.options(load_only(*loadable_fields)) result = await db.execute(statement) return result.scalars().first() async def upsert_settings(db: AsyncSession, data: SiteSettingsUpdate) -> SiteSettings: settings_row = await get_settings(db) if settings_row is None: # Create with defaults + provided values init_data = { "site_name": "", "tagline": None, "logo_url": None, "footer_text": None, "social_links": {}, "automatic_member_notifications_enabled": True, "nz_public_holiday_notifications_enabled": True, "invoice_reminder_notifications_enabled": True, "invoice_day_of_week": 1, "admin_notifications_cleared_before": None, "bookings_enabled": True, "walks_enabled": True, "messages_enabled": True, "two_factor_enabled": True, "audit_history_enabled": True, "experiments_enabled": True, "service_pricing": default_service_pricing(), } update_data = data.model_dump(exclude_unset=True) init_data.update(update_data) settings_row = SiteSettings(**init_data) db.add(settings_row) else: update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(settings_row, field, value) await db.flush() await db.refresh(settings_row) return settings_row async def get_feature_settings_snapshot(db: AsyncSession) -> FeatureSettingsSnapshot: existing_columns = await _get_site_settings_column_names(db) row = await get_settings(db, existing_columns=existing_columns) if row is None: return FeatureSettingsSnapshot() return FeatureSettingsSnapshot( bookings_enabled=getattr(row, "bookings_enabled", True) if "bookings_enabled" in existing_columns else True, walks_enabled=getattr(row, "walks_enabled", True) if "walks_enabled" in existing_columns else True, messages_enabled=getattr(row, "messages_enabled", True) if "messages_enabled" in existing_columns else True, two_factor_enabled=getattr(row, "two_factor_enabled", True) if "two_factor_enabled" in existing_columns else True, audit_history_enabled=getattr(row, "audit_history_enabled", True) if "audit_history_enabled" in existing_columns else True, experiments_enabled=getattr(row, "experiments_enabled", True) if "experiments_enabled" in existing_columns else True, ) async def update_feature_settings_snapshot( db: AsyncSession, data: FeatureSettingsUpdate, ) -> FeatureSettingsSnapshot: existing_columns = await _get_site_settings_column_names(db) requested_fields = set(data.model_dump(exclude_unset=True).keys()) missing_columns = sorted(field for field in requested_fields if field not in existing_columns) if missing_columns: raise FeatureSettingsSchemaOutdatedError( "Feature toggle columns are missing from site_settings. Run alembic upgrade head." ) row = await get_settings(db, existing_columns=existing_columns) if row is None: row = SiteSettings(site_name="") db.add(row) await db.flush() update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(row, field, value) await db.flush() await db.refresh(row) return await get_feature_settings_snapshot(db) async def get_service_pricing_snapshot(db: AsyncSession) -> dict[str, dict[str, float | str]]: existing_columns = await _get_site_settings_column_names(db) row = await get_settings(db, existing_columns=existing_columns) if row is None: return default_service_pricing() if "service_pricing" not in existing_columns: return default_service_pricing() return normalize_service_pricing(getattr(row, "service_pricing", None)) async def update_service_pricing_snapshot( db: AsyncSession, *, service_pricing: dict, ) -> dict[str, dict[str, float | str]]: existing_columns = await _get_site_settings_column_names(db) if "service_pricing" not in existing_columns: raise ServicePricingSchemaOutdatedError( "Service pricing columns are missing from site_settings. Run alembic upgrade head." ) row = await get_settings(db, existing_columns=existing_columns) if row is None: row = SiteSettings(site_name="", service_pricing=default_service_pricing()) db.add(row) await db.flush() row.service_pricing = normalize_service_pricing(service_pricing) await db.flush() await db.refresh(row) return await get_service_pricing_snapshot(db)