from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from sqlalchemy.exc import OperationalError from app.config import get_settings from app.database import engine from app.models import Base from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard from app.routers import morning_routine, break_activity from app.websocket.manager import manager settings = get_settings() async def _add_column_if_missing(conn, table: str, column: str, definition: str): """Add a column to a table, silently ignoring if it already exists (MySQL 1060).""" try: await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")) except OperationalError as e: if e.orig.args[0] != 1060: # 1060 = Duplicate column name raise @asynccontextmanager async def lifespan(app: FastAPI): # Create tables on startup (Alembic handles migrations in prod, this is a safety net) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Idempotent column additions for schema migrations await _add_column_if_missing(conn, "schedule_blocks", "duration_minutes", "INT NULL") await _add_column_if_missing(conn, "schedule_blocks", "break_time_enabled", "TINYINT(1) NOT NULL DEFAULT 0") await _add_column_if_missing(conn, "schedule_blocks", "break_time_minutes", "INT NULL") await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0") await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'") yield app = FastAPI( title="Homeschool API", version="1.0.0", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Routers app.include_router(auth.router) app.include_router(users.router) app.include_router(children.router) app.include_router(subjects.router) app.include_router(schedules.router) app.include_router(sessions.router) app.include_router(logs.router) app.include_router(morning_routine.router) app.include_router(break_activity.router) app.include_router(dashboard.router) @app.get("/api/health") async def health(): return {"status": "ok"} @app.websocket("/ws/{child_id}") async def websocket_endpoint(websocket: WebSocket, child_id: int): await manager.connect(websocket, child_id) try: while True: # Keep connection alive; TV clients are receive-only await websocket.receive_text() except WebSocketDisconnect: manager.disconnect(websocket, child_id)