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.
|
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.
|
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
|
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 |
|
| 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
|
### API Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import random
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
@@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
|
from app.models.child import Child
|
||||||
from app.models.subject import Subject
|
from app.models.subject import Subject
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
|
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, "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, "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", "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
|
# Seed Meeting system subject for any existing users who don't have one
|
||||||
from app.database import AsyncSessionLocal
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
users_result = await db.execute(select(User).where(User.is_active == True))
|
users_result = await db.execute(select(User).where(User.is_active == True))
|
||||||
all_users = users_result.scalars().all()
|
all_users = users_result.scalars().all()
|
||||||
@@ -100,12 +120,19 @@ async def health():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/{child_id}")
|
@app.websocket("/ws/{tv_token}")
|
||||||
async def websocket_endpoint(websocket: WebSocket, child_id: int):
|
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)
|
await manager.connect(websocket, child_id)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# Keep connection alive; TV clients are receive-only
|
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
manager.disconnect(websocket, child_id)
|
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
|
color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI
|
||||||
strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
strikes_last_reset: Mapped[Optional[date]] = mapped_column(Date, nullable=True, default=None)
|
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
|
user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821
|
||||||
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
|
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import random
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
@@ -49,13 +50,22 @@ async def list_children(
|
|||||||
return 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)
|
@router.post("", response_model=ChildOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_child(
|
async def create_child(
|
||||||
body: ChildCreate,
|
body: ChildCreate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
db.add(child)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(child)
|
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 = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{child_id}", response_model=DashboardSnapshot)
|
@router.get("/{tv_token}", response_model=DashboardSnapshot)
|
||||||
async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
|
async def get_dashboard(tv_token: int, db: AsyncSession = Depends(get_db)):
|
||||||
child_result = await db.execute(select(Child).where(Child.id == child_id, Child.is_active == True))
|
child_result = await db.execute(select(Child).where(Child.tv_token == tv_token, Child.is_active == True))
|
||||||
child = child_result.scalar_one_or_none()
|
child = child_result.scalar_one_or_none()
|
||||||
if not child:
|
if not child:
|
||||||
raise HTTPException(status_code=404, detail="Child not found")
|
raise HTTPException(status_code=404, detail="Child not found")
|
||||||
|
child_id = child.id
|
||||||
|
|
||||||
# Get today's active session
|
# Get today's active session
|
||||||
session_result = await db.execute(
|
session_result = await db.execute(
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ class ChildOut(BaseModel):
|
|||||||
is_active: bool
|
is_active: bool
|
||||||
color: str
|
color: str
|
||||||
strikes: int = 0
|
strikes: int = 0
|
||||||
|
tv_token: int | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="card tv-card">
|
<div class="card tv-card">
|
||||||
<div class="card-title">TV Dashboard</div>
|
<div class="card-title">TV Dashboard</div>
|
||||||
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p>
|
<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 →
|
Open TV View →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user