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:
2026-03-10 22:53:26 -07:00
parent 4bd9218bf5
commit 68a5e9cb4f
7 changed files with 52 additions and 12 deletions

View File

@@ -181,7 +181,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
5. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here.
6. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes.
7. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template
8. **TV**Open `http://your-lan-ip:8054/tv/1` on the living room TV (replace `1` with the child's ID)
8. **TV**From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV.
---
@@ -236,9 +236,9 @@ Pressing **Reset** after **Done** fully un-marks the block — removes the check
| URL | Description |
|-----|-------------|
| `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay |
| `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay |
Point a browser on the living room TV at `http://your-lan-ip:8054/tv/1`. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.
Each child is assigned a permanent random 4-digit token when created (e.g. `/tv/4823`). The token never changes and does not expose the internal database ID. Find the TV URL for a child by clicking **Open TV View** on the Dashboard. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.
### API Documentation

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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}

View File

@@ -38,7 +38,7 @@
<div class="card tv-card">
<div class="card-title">TV Dashboard</div>
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p>
<a :href="`/tv/${activeChild.id}`" target="_blank" class="btn-primary">
<a :href="`/tv/${activeChild.tv_token}`" target="_blank" class="btn-primary">
Open TV View
</a>
</div>