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