import random 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.child import Child 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") await _add_column_if_missing(conn, "users", "last_active_at", "DATETIME NULL") await _add_column_if_missing(conn, "children", "strikes_last_reset", "DATE NULL") await _add_column_if_missing(conn, "children", "tv_token", "INT NULL") # Backfill tv_token for existing children that don't have one from app.database import AsyncSessionLocal async with AsyncSessionLocal() as db: result = await db.execute(select(Child).where(Child.tv_token == None)) # noqa: E711 children_without_token = result.scalars().all() used_tokens = set() for child in children_without_token: while True: token = random.randint(1000, 9999) if token not in used_tokens: existing = await db.execute(select(Child).where(Child.tv_token == token)) if not existing.scalar_one_or_none(): break child.tv_token = token used_tokens.add(token) if children_without_token: await db.commit() # Seed Meeting system subject for any existing users who don't have one 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/{tv_token}") async def websocket_endpoint(websocket: WebSocket, tv_token: int): from app.database import AsyncSessionLocal async with AsyncSessionLocal() as db: result = await db.execute(select(Child).where(Child.tv_token == tv_token)) child = result.scalar_one_or_none() if not child: await websocket.close(code=4004) return child_id = child.id await manager.connect(websocket, child_id) try: while True: await websocket.receive_text() except WebSocketDisconnect: manager.disconnect(websocket, child_id)