Files
homeschool/backend/app/main.py
derekc 68a5e9cb4f Add random 4-digit TV token per child for obfuscated TV URLs
Each child is assigned a unique permanent tv_token on creation. The TV
dashboard URL (/tv/:tvToken) and WebSocket (/ws/:tvToken) now use this
token instead of the internal DB ID. Existing children are backfilled
on startup. README updated to reflect the change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 22:53:26 -07:00

139 lines
5.2 KiB
Python

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)