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:
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user