from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import func, select from app.config import settings from app.database import async_session, engine from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter from app.user_models import User from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters def _migrate_add_missing_columns(sync_conn): """Add missing columns to existing tables (startup migration — no Alembic). Handles both SQLite and PostgreSQL. Idempotent: skips columns that already exist. Receives a sync SQLAlchemy Connection from engine.run_sync(). """ import sqlalchemy as sa # Column definitions: (table, name, type, default) pending = [ ("station_config", "stream_url", sa.String(500), ""), ("station_config", "stream_metadata_url", sa.String(500), ""), ("station_config", "play_store_icon_url", sa.String(500), ""), ("station_config", "play_store_url", sa.String(500), ""), ("station_config", "app_store_embed_html", sa.Text, ""), ] # Determine dialect dialect = sync_conn.dialect.name # "sqlite" | "postgresql" inspector = sa.inspect(sync_conn) for table_name, col_name, col_type, default in pending: # Check if column already exists existing = inspector.get_columns(table_name) if any(c["name"] == col_name for c in existing): continue print(f" → Migrating: adding column {table_name}.{col_name}") # Determine SQL type from the column type object if isinstance(col_type, sa.Text): sql_type = "TEXT" elif isinstance(col_type, sa.String): sql_type = "VARCHAR(500)" else: sql_type = "TEXT" if dialect == "sqlite": sync_conn.execute( sa.text(f'ALTER TABLE {table_name} ADD COLUMN "{col_name}" TEXT NOT NULL DEFAULT "{default}"') ) else: sync_conn.execute( sa.text(f'ALTER TABLE {table_name} ADD COLUMN "{col_name}" {sql_type} NOT NULL DEFAULT "{default}"') ) print(f" ✓ Added {table_name}.{col_name}") async def _ensure_station_config(session): """Ensure the singleton station config row exists (idempotent).""" result = await session.execute( select(StationConfig).where(StationConfig.key == "default") ) if result.scalar_one_or_none() is None: print(" → Station config missing — seeding defaults...") from seed import seed as run_seed # Only seed the station config, not the full dataset from seed import STATION_CONFIG, _upsert_station_config await _upsert_station_config(session, STATION_CONFIG) await session.commit() print(" ✓ Station config seeded") @asynccontextmanager async def lifespan(app: FastAPI): # Create tables on startup, then patch any missing columns async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) await conn.run_sync(_migrate_add_missing_columns) # Auto-seed if database is empty async with async_session() as session: result = await session.execute(select(func.count(Show.id))) count = result.scalar() if count == 0: print(" → Database is empty — running seed...") from seed import seed as run_seed await run_seed() return # Full seed already included station config # Ensure station config exists (runs even if DB already had data) async with async_session() as session: await _ensure_station_config(session) # Bootstrap admin user if configured if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD: async with async_session() as session: result = await session.execute( select(User).where( User.email == f"{settings.ADMIN_USERNAME}@local", User.auth_provider == "local", ) ) if result.scalar_one_or_none() is None: admin_user = User( email=f"{settings.ADMIN_USERNAME}@local", display_name=settings.ADMIN_USERNAME, auth_provider="local", is_admin=True, ) session.add(admin_user) await session.commit() print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}") yield app = FastAPI( title="KMountain Flower Radio API", description="Backend API for the KMountain Flower Radio Station website.", version="1.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Register routers app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(events.router, prefix="/api/events", tags=["events"]) app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"]) app.include_router(history.router, prefix="/api/history", tags=["history"]) app.include_router(team.router, prefix="/api/team", tags=["team"]) app.include_router(community.router, prefix="/api/community", tags=["community"]) app.include_router(shows.router, prefix="/api/shows", tags=["shows"]) app.include_router(storage.router, prefix="/api/storage", tags=["storage"]) app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"]) # Dev-only admin router (disabled in production) if settings.ENV != "production": from app.api import admin app.include_router(admin.router, prefix="/api/admin", tags=["admin"])