Files
kmtnflower/backend/app/main.py
T

207 lines
7.9 KiB
Python

from contextlib import asynccontextmanager
import asyncio
import os
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, stats
def _cleanup_orphaned_sequences(sync_conn):
"""Drop model sequences whose backing table no longer exists.
Prevents 'duplicate key value violates unique constraint' errors when
create_all() tries to recreate a SERIAL-backed sequence for a table
whose original sequence was left behind after a drop/recreate cycle.
Only runs on PostgreSQL; no-op for SQLite.
"""
import sqlalchemy as sa
if sync_conn.dialect.name != "postgresql":
return
inspector = sa.inspect(sync_conn)
existing_tables = set(inspector.get_table_names())
for table in Base.metadata.tables.values():
expected_seq = f"{table.name}_id_seq"
if table.name not in existing_tables:
sync_conn.execute(sa.text(f"DROP SEQUENCE IF EXISTS {expected_seq}"))
print(f" → Dropped orphaned sequence {expected_seq}")
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(_cleanup_orphaned_sequences)
try:
await conn.run_sync(Base.metadata.create_all)
except Exception as e:
if "duplicate" in str(e).lower():
print(f" → Tables already exist (partial creation from prior restart): {e}")
else:
raise
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()
print(" ✓ Full seed completed (skipping station config check)")
# 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}")
# Check GeoLite2 database
geo_db = settings.GEOLITE2_DB_PATH
if os.path.exists(geo_db):
print(f" ✓ GeoLite2 database loaded from {geo_db}")
else:
print(f" WARNING: GeoLite2 database not found at {geo_db} — geo lookups will be disabled")
# Auto-parse any unprocessed logs on startup (background task)
async def _startup_parse():
try:
from app.log_parser import process_unparsed_logs
result = await process_unparsed_logs()
if result.get("files_processed"):
print(f" ✓ Parsed {result['files_processed']} log files ({result['rows_inserted']} geo rows)")
else:
print(" No unprocessed log files to parse")
except Exception as e:
print(f" ERROR during startup log parse: {e}")
asyncio.create_task(_startup_parse())
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"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
# 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"])