from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select, text from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.database import engine from app.models import Base from app.models.subject import Subject from app.models.user import User from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard from app.routers import morning_routine, break_activity, admin 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'") await _add_column_if_missing(conn, "subjects", "is_system", "TINYINT(1) NOT NULL DEFAULT 0") # Seed Meeting system subject for any existing users who don't have one from app.database import AsyncSessionLocal async with AsyncSessionLocal() as db: users_result = await db.execute(select(User).where(User.is_active == True)) all_users = users_result.scalars().all() for user in all_users: existing = await db.execute( select(Subject).where(Subject.user_id == user.id, Subject.is_system == True) ) if not existing.scalar_one_or_none(): db.add(Subject( user_id=user.id, name="Meeting", icon="📅", color="#6366f1", is_system=True, )) await db.commit() 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.include_router(admin.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)