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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import random
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
@@ -9,6 +10,7 @@ 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
|
||||
@@ -41,9 +43,27 @@ async def lifespan(app: FastAPI):
|
||||
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
|
||||
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()
|
||||
@@ -100,12 +120,19 @@ async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.websocket("/ws/{child_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, child_id: int):
|
||||
@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:
|
||||
# Keep connection alive; TV clients are receive-only
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket, child_id)
|
||||
|
||||
@@ -16,6 +16,7 @@ class Child(TimestampMixin, Base):
|
||||
color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI
|
||||
strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
strikes_last_reset: Mapped[Optional[date]] = mapped_column(Date, nullable=True, default=None)
|
||||
tv_token: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, unique=True)
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821
|
||||
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
@@ -49,13 +50,22 @@ async def list_children(
|
||||
return children
|
||||
|
||||
|
||||
async def _generate_tv_token(db: AsyncSession) -> int:
|
||||
while True:
|
||||
token = random.randint(1000, 9999)
|
||||
result = await db.execute(select(Child).where(Child.tv_token == token))
|
||||
if not result.scalar_one_or_none():
|
||||
return token
|
||||
|
||||
|
||||
@router.post("", response_model=ChildOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_child(
|
||||
body: ChildCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
child = Child(**body.model_dump(), user_id=current_user.id)
|
||||
tv_token = await _generate_tv_token(db)
|
||||
child = Child(**body.model_dump(), user_id=current_user.id, tv_token=tv_token)
|
||||
db.add(child)
|
||||
await db.commit()
|
||||
await db.refresh(child)
|
||||
|
||||
@@ -22,12 +22,13 @@ from app.utils.timer import compute_block_elapsed, compute_break_elapsed
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/{child_id}", response_model=DashboardSnapshot)
|
||||
async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
|
||||
child_result = await db.execute(select(Child).where(Child.id == child_id, Child.is_active == True))
|
||||
@router.get("/{tv_token}", response_model=DashboardSnapshot)
|
||||
async def get_dashboard(tv_token: int, db: AsyncSession = Depends(get_db)):
|
||||
child_result = await db.execute(select(Child).where(Child.tv_token == tv_token, Child.is_active == True))
|
||||
child = child_result.scalar_one_or_none()
|
||||
if not child:
|
||||
raise HTTPException(status_code=404, detail="Child not found")
|
||||
child_id = child.id
|
||||
|
||||
# Get today's active session
|
||||
session_result = await db.execute(
|
||||
|
||||
@@ -23,5 +23,6 @@ class ChildOut(BaseModel):
|
||||
is_active: bool
|
||||
color: str
|
||||
strikes: int = 0
|
||||
tv_token: int | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
Reference in New Issue
Block a user