Files
homeschool/backend/app/main.py
derekc f645d78c83 Add Meeting system subject and notification system
- Auto-create a locked "Meeting" subject for every user on registration
  and seed it for all existing users on startup
- Meeting subject cannot be deleted or renamed (is_system flag)
- 5-minute corner toast warning on Dashboard and TV with live countdown,
  dismiss button, and 1-minute re-notify if dismissed
- At start time: full-screen TV overlay with 30-second auto-dismiss,
  automatic pause of running block, switch to Meeting block, and
  auto-start of Meeting timer
- Web Audio API chimes: rising on warnings, falling at meeting start
- Update README with Meeting subject and notification system docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 23:44:21 -08:00

110 lines
3.9 KiB
Python

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)